From 18958c50010ee4658e8f38e4cd54c39dbd38615f Mon Sep 17 00:00:00 2001 From: fy59 Date: Sun, 6 Sep 2026 19:25:13 +0200 Subject: [PATCH] Add shared bidirectional sync engine --- .gitignore | 2 +- CHANGELOG.md | 113 + .../trainlog/data/SyncCatalogInbox.kt | 289 ++ .../labfytools/trainlog/data/SyncExporter.kt | 253 ++ .../trainlog/data/SyncRequestOutbox.kt | 251 ++ .../trainlog/data/TrainlogRepository.kt | 1745 +++++++++++ .../com/labfytools/trainlog/ui/SyncScreen.kt | 188 +- docs/android.md | 113 + docs/roadmap.md | 113 + docs/sync_exchange.md | 113 + docs/tui.md | 113 + tools/install_syncd_user.sh | 50 + tools/trainlog_syncd.py | 138 + tui/include/trainlog/sync.h | 91 + tui/meson.build | 8 + tui/src/id.c | 3 +- tui/src/sync.c | 2634 +++++++++++++++++ tui/src/tui.c | 2048 ++++--------- tui/tests/test_database.c | 23 + tui/tools/sync_once.c | 176 ++ 20 files changed, 6905 insertions(+), 1559 deletions(-) create mode 100644 android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt create mode 100644 android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt create mode 100644 android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt create mode 100644 android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt create mode 100755 tools/install_syncd_user.sh create mode 100755 tools/trainlog_syncd.py create mode 100644 tui/include/trainlog/sync.h create mode 100644 tui/src/sync.c create mode 100644 tui/tools/sync_once.c diff --git a/.gitignore b/.gitignore index f15215f..c9a8c99 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ local.properties *.db-wal # Local user data -data/ +/data/ exports/ # Python helpers diff --git a/CHANGELOG.md b/CHANGELOG.md index 78c1706..a5184c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -280,3 +280,116 @@ The database replacement remains transactional. `TRAINLOG_FORMAT_V1` remains frozen and unchanged. + + +## Shared bidirectional synchronization v1 + +Validated architecture: + +```text +Android local write + -> automatic mobile snapshot + +Android "Synchroniser maintenant" + -> trainlog-sync-request-v1.json + +trainlog-syncd + -> shared C synchronization engine + -> Android → PC mobile import + -> PC → Android catalog publish + -> trainlog-sync-receipt-v1.json + +Android + -> receipt matched by request_id + -> PC catalog applied locally + -> final result displayed +``` + +The ncurses TUI and `trainlog-syncd` call the same +`trainlog_sync_run()` implementation. + +Direct libmtp remains mandatory. No filesystem mount and no SQLite-file +synchronization are introduced. + +### Concurrency + +The shared engine owns: + +```text +$XDG_DATA_HOME/trainlog/sync.lock +``` + +A TUI-triggered transaction waits for the lock. Daemon request polling is +non-blocking and retries later. + +### Sync history + +Every actual synchronization transaction creates: + +```text +$XDG_DATA_HOME/trainlog/sync_runs/sy_*.json +$XDG_DATA_HOME/trainlog/sync_runs/sy_*.txt +``` + +and appends a compact entry to: + +```text +$XDG_DATA_HOME/trainlog/sync_history.log +``` + +The TUI behaves like: + +```text +git log + ↑/↓ select synchronization + +git show + Enter opens structured detail +``` + +Legacy three-field history entries remain readable but have no structured +detail file. + +### Android request and receipt + +Request: + +```text +format = trainlog-sync-request +version = 1 +``` + +Receipt: + +```text +format = trainlog-sync-receipt +version = 1 +``` + +The receipt carries the originating `request_id`, a generated `sync_id`, +status, summary and synchronization counts. Android ignores a receipt for a +different request ID. + +### User service + +Install/refresh the user service with: + +```text +bash tools/install_syncd_user.sh +``` + +No root privilege is required. + +### Status + +```text +COMMON_SYNC_ENGINE=PASS +TUI_SYNC_LOG_SHOW=PASS +TRAINLOG_SYNCD=PASS +ANDROID_TRIGGERED_SYNC=PASS +ANDROID_SYNC_RECEIPT=PASS +BIDIRECTIONAL_SYNC_V1=PASS +``` + +Frozen `TRAINLOG_FORMAT_V1` remains unchanged. + diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt new file mode 100644 index 0000000..0ddab64 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt @@ -0,0 +1,289 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import org.json.JSONObject + +sealed interface CatalogInboxResult { + data class Imported( + val imported: Int, + val reconciled: Int, + val skipped: Int, + ) : CatalogInboxResult + + data object FolderNotAuthorized : + CatalogInboxResult + + data object FileNotFound : + CatalogInboxResult + + data class Error( + val message: String, + ) : CatalogInboxResult +} + +sealed interface SyncReceiptResult { + data object Pending : + SyncReceiptResult + + data object FolderNotAuthorized : + SyncReceiptResult + + data class Received( + val syncId: String, + val success: Boolean, + val summary: String, + ) : SyncReceiptResult + + data class Error( + val message: String, + ) : SyncReceiptResult +} + +class SyncCatalogInbox( + context: Context, + private val repository: + TrainlogRepository, +) { + private val appContext = + context.applicationContext + + private val preferences = + appContext.getSharedPreferences( + "trainlog-sync", + Context.MODE_PRIVATE, + ) + + fun hasFolderAccess(): Boolean = + savedTreeUri() != null + + fun saveTreeUri( + uri: Uri, + ): Boolean { + return try { + appContext + .contentResolver + .takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + + preferences + .edit() + .putString( + KEY_TREE_URI, + uri.toString(), + ) + .apply() + + true + } catch ( + error: SecurityException + ) { + false + } + } + + fun importPcCatalog(): + CatalogInboxResult { + val treeUri = + savedTreeUri() + ?: return CatalogInboxResult + .FolderNotAuthorized + + val directory = + DocumentFile + .fromTreeUri( + appContext, + treeUri, + ) + ?: return CatalogInboxResult.Error( + "Dossier Trainlog inaccessible." + ) + + val file = + directory.findFile( + "trainlog-pc-catalog-v1.json" + ) + ?: return CatalogInboxResult.FileNotFound + + return try { + val stream = + appContext + .contentResolver + .openInputStream( + file.uri + ) + ?: return CatalogInboxResult.Error( + "Lecture du catalogue impossible." + ) + + val json = + stream.bufferedReader( + Charsets.UTF_8 + ) + .use { + it.readText() + } + + when ( + val result = + repository + .applyPcCatalogJson( + json + ) + ) { + is PcCatalogImportResult.Applied -> + CatalogInboxResult.Imported( + imported = + result.imported, + reconciled = + result.reconciled, + skipped = + result.skipped, + ) + + is PcCatalogImportResult.Invalid -> + CatalogInboxResult.Error( + result.message + ) + + PcCatalogImportResult.DatabaseError -> + CatalogInboxResult.Error( + "Erreur base locale." + ) + } + } catch ( + error: Exception + ) { + CatalogInboxResult.Error( + error.message + ?: "Import catalogue impossible." + ) + } + } + +fun readSyncReceipt( + requestId: String, + ): SyncReceiptResult { + val treeUri = + savedTreeUri() + ?: return SyncReceiptResult + .FolderNotAuthorized + + val directory = + DocumentFile + .fromTreeUri( + appContext, + treeUri, + ) + ?: return SyncReceiptResult.Error( + "Dossier Trainlog inaccessible." + ) + + val file = + directory.findFile( + "trainlog-sync-receipt-v1.json" + ) + ?: return SyncReceiptResult.Pending + + return try { + val stream = + appContext + .contentResolver + .openInputStream( + file.uri + ) + ?: return SyncReceiptResult.Error( + "Lecture du reçu impossible." + ) + + val json = + stream.bufferedReader( + Charsets.UTF_8 + ) + .use { + it.readText() + } + + val root = + JSONObject(json) + + if ( + root.optString( + "format" + ) != + "trainlog-sync-receipt" || + root.optInt( + "version", + -1 + ) != 1 + ) { + return SyncReceiptResult.Error( + "Reçu de synchronisation invalide." + ) + } + + if ( + root.optString( + "request_id" + ) != requestId + ) { + return SyncReceiptResult.Pending + } + + val status = + root.optString( + "status" + ) + + if ( + status != "success" && + status != "failure" + ) { + return SyncReceiptResult.Error( + "État de synchronisation invalide." + ) + } + + SyncReceiptResult.Received( + syncId = + root.optString( + "sync_id" + ), + success = + status == "success", + summary = + root.optString( + "summary", + "Synchronisation terminée." + ), + ) + } catch ( + error: Exception + ) { + SyncReceiptResult.Error( + error.message + ?: "Lecture du reçu impossible." + ) + } + } + + private fun savedTreeUri(): Uri? = + preferences + .getString( + KEY_TREE_URI, + null, + ) + ?.let { + Uri.parse(it) + } + + private companion object { + const val KEY_TREE_URI = + "trainlog_tree_uri" + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt new file mode 100644 index 0000000..685b1da --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt @@ -0,0 +1,253 @@ +package com.labfytools.trainlog.data + +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore + +sealed interface SyncExportResult { + data class Exported( + val displayPath: String, + val bytes: Int, + ) : SyncExportResult + + data object Unsupported : + SyncExportResult + + data class Error( + val message: String, + ) : SyncExportResult +} + +class SyncExporter( + context: Context, + private val repository: + TrainlogRepository, +) { + private val appContext = + context.applicationContext + + fun exportMobileBundle(): + SyncExportResult { + if ( + Build.VERSION.SDK_INT < + Build.VERSION_CODES.Q + ) { + return SyncExportResult.Unsupported + } + + val json = + repository.buildMobileExportJson() + + val bytes = + json.toByteArray( + Charsets.UTF_8 + ) + + val resolver = + appContext.contentResolver + + val collection = + MediaStore.Downloads + .getContentUri( + MediaStore + .VOLUME_EXTERNAL_PRIMARY + ) + + val relativePath = + Environment.DIRECTORY_DOWNLOADS + + "/Trainlog/" + + val displayName = + "trainlog-mobile-export-v1.json" + + val existing = + findExisting( + collection, + displayName, + relativePath + ) + + val targetUri: Uri + val created: Boolean + + if (existing != null) { + targetUri = existing + created = false + } else { + val values = + ContentValues().apply { + put( + MediaStore + .MediaColumns + .DISPLAY_NAME, + displayName + ) + + put( + MediaStore + .MediaColumns + .MIME_TYPE, + "application/json" + ) + + put( + MediaStore + .MediaColumns + .RELATIVE_PATH, + relativePath + ) + + put( + MediaStore + .MediaColumns + .IS_PENDING, + 1 + ) + } + + val inserted = + resolver.insert( + collection, + values + ) + + if (inserted == null) { + return SyncExportResult.Error( + "Création du fichier impossible." + ) + } + + targetUri = inserted + created = true + } + + try { + val stream = + resolver.openOutputStream( + targetUri, + "wt" + ) + + if (stream == null) { + if (created) { + resolver.delete( + targetUri, + null, + null + ) + } + + return SyncExportResult.Error( + "Flux d'écriture indisponible." + ) + } + + stream.use { + it.write(bytes) + it.flush() + } + + if (created) { + val finished = + ContentValues().apply { + put( + MediaStore + .MediaColumns + .IS_PENDING, + 0 + ) + } + + resolver.update( + targetUri, + finished, + null, + null + ) + } + + return SyncExportResult.Exported( + displayPath = + "Download/Trainlog/" + + displayName, + bytes = + bytes.size, + ) + } catch ( + error: Exception + ) { + if (created) { + resolver.delete( + targetUri, + null, + null + ) + } + + return SyncExportResult.Error( + error.message + ?: "Erreur d'export." + ) + } + } + + private fun findExisting( + collection: Uri, + displayName: String, + relativePath: String, + ): Uri? { + val projection = + arrayOf( + MediaStore + .MediaColumns + ._ID + ) + + val selection = + ( + MediaStore + .MediaColumns + .DISPLAY_NAME + + " = ? AND " + + MediaStore + .MediaColumns + .RELATIVE_PATH + + " = ?" + ) + + val cursor = + appContext + .contentResolver + .query( + collection, + projection, + selection, + arrayOf( + displayName, + relativePath, + ), + null, + ) + ?: return null + + try { + if (!cursor.moveToFirst()) { + return null + } + + val itemId = + cursor.getLong(0) + + return ContentUris.withAppendedId( + collection, + itemId + ) + } finally { + cursor.close() + } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt new file mode 100644 index 0000000..3aebc53 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt @@ -0,0 +1,251 @@ +package com.labfytools.trainlog.data + +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import org.json.JSONObject +import java.time.OffsetDateTime +import java.util.UUID + +sealed interface SyncRequestResult { + data class Requested( + val requestId: String, + ) : SyncRequestResult + + data object Unsupported : + SyncRequestResult + + data class Error( + val message: String, + ) : SyncRequestResult +} + +class SyncRequestOutbox( + context: Context, +) { + private val appContext = + context.applicationContext + + fun requestSync(): + SyncRequestResult { + if ( + Build.VERSION.SDK_INT < + Build.VERSION_CODES.Q + ) { + return SyncRequestResult.Unsupported + } + + val requestId = + "sr_" + + UUID.randomUUID() + .toString() + + val payload = + JSONObject() + .put( + "format", + "trainlog-sync-request", + ) + .put( + "version", + 1, + ) + .put( + "request_id", + requestId, + ) + .put( + "requested_at", + OffsetDateTime.now() + .toString(), + ) + .toString() + + val bytes = + payload.toByteArray( + Charsets.UTF_8 + ) + + val resolver = + appContext.contentResolver + + val collection = + MediaStore.Downloads + .getContentUri( + MediaStore + .VOLUME_EXTERNAL_PRIMARY + ) + + val relativePath = + Environment + .DIRECTORY_DOWNLOADS + + "/Trainlog/" + + val displayName = + "trainlog-sync-request-v1.json" + + val existing = + findExisting( + collection, + displayName, + relativePath, + ) + + val targetUri: Uri + val created: Boolean + + if (existing != null) { + targetUri = existing + created = false + } else { + val values = + ContentValues().apply { + put( + MediaStore + .MediaColumns + .DISPLAY_NAME, + displayName + ) + + put( + MediaStore + .MediaColumns + .MIME_TYPE, + "application/json" + ) + + put( + MediaStore + .MediaColumns + .RELATIVE_PATH, + relativePath + ) + + put( + MediaStore + .MediaColumns + .IS_PENDING, + 1 + ) + } + + val inserted = + resolver.insert( + collection, + values + ) + ?: return SyncRequestResult.Error( + "Création de la demande impossible." + ) + + targetUri = inserted + created = true + } + + return try { + val stream = + resolver.openOutputStream( + targetUri, + "wt" + ) + ?: return SyncRequestResult.Error( + "Écriture de la demande impossible." + ) + + stream.use { + it.write(bytes) + it.flush() + } + + if (created) { + val finished = + ContentValues().apply { + put( + MediaStore + .MediaColumns + .IS_PENDING, + 0 + ) + } + + resolver.update( + targetUri, + finished, + null, + null + ) + } + + SyncRequestResult.Requested( + requestId + ) + } catch ( + error: Exception + ) { + if (created) { + resolver.delete( + targetUri, + null, + null + ) + } + + SyncRequestResult.Error( + error.message + ?: "Demande de synchronisation impossible." + ) + } + } + + private fun findExisting( + collection: Uri, + displayName: String, + relativePath: String, + ): Uri? { + val cursor = + appContext + .contentResolver + .query( + collection, + arrayOf( + MediaStore + .MediaColumns + ._ID + ), + ( + MediaStore + .MediaColumns + .DISPLAY_NAME + + " = ? AND " + + MediaStore + .MediaColumns + .RELATIVE_PATH + + " = ?" + ), + arrayOf( + displayName, + relativePath, + ), + null, + ) + ?: return null + + try { + if (!cursor.moveToFirst()) { + return null + } + + return ContentUris + .withAppendedId( + collection, + cursor.getLong(0) + ) + } finally { + cursor.close() + } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt new file mode 100644 index 0000000..185ef6c --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -0,0 +1,1745 @@ +package com.labfytools.trainlog.data + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteConstraintException +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import com.labfytools.trainlog.model.BodyObservationDraft +import com.labfytools.trainlog.model.BodyObservationSummary +import com.labfytools.trainlog.model.ExerciseProfile +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionDraft +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionSummary +import com.labfytools.trainlog.model.SessionDetail +import com.labfytools.trainlog.model.SessionExerciseDetail +import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.TrackingMode +import org.json.JSONArray +import org.json.JSONObject +import java.text.Normalizer +import java.time.OffsetDateTime +import java.util.Locale +import java.util.UUID + +sealed interface CreateExerciseResult { + data class Created( + val exercise: ExerciseProfile, + ) : CreateExerciseResult + + data object Conflict : + CreateExerciseResult + + data object Invalid : + CreateExerciseResult +} + + +sealed interface PcCatalogImportResult { + data class Applied( + val imported: Int, + val reconciled: Int, + val skipped: Int, + ) : PcCatalogImportResult + + data class Invalid( + val message: String, + ) : PcCatalogImportResult + + data object DatabaseError : + PcCatalogImportResult +} + +sealed interface SaveBodyObservationResult { + data class Saved( + val observationId: String, + ) : SaveBodyObservationResult + + data object Invalid : + SaveBodyObservationResult + + data object DatabaseError : + SaveBodyObservationResult +} + +sealed interface SaveSessionResult { + data class Saved( + val sessionId: String, + ) : SaveSessionResult + + data object Invalid : + SaveSessionResult + + data object DatabaseError : + SaveSessionResult +} + +class TrainlogRepository( + context: Context, +) { + private val database = + TrainlogDatabaseHelper( + context.applicationContext + ) + + fun listExercises(): List { + val output = + mutableListOf() + + database.readableDatabase.query( + "exercises", + arrayOf( + "exercise_id", + "name", + "normalized_name", + "recording_mode", + "tracking_mode", + "data_fields", + ), + null, + null, + null, + null, + "name COLLATE NOCASE, exercise_id", + ).use { cursor -> + val idIndex = + cursor.getColumnIndexOrThrow( + "exercise_id" + ) + + val nameIndex = + cursor.getColumnIndexOrThrow( + "name" + ) + + val normalizedIndex = + cursor.getColumnIndexOrThrow( + "normalized_name" + ) + + val recordingIndex = + cursor.getColumnIndexOrThrow( + "recording_mode" + ) + + val trackingIndex = + cursor.getColumnIndexOrThrow( + "tracking_mode" + ) + + val fieldsIndex = + cursor.getColumnIndexOrThrow( + "data_fields" + ) + + while (cursor.moveToNext()) { + output += + ExerciseProfile( + exerciseId = + cursor.getString( + idIndex + ), + name = + cursor.getString( + nameIndex + ), + normalizedName = + cursor.getString( + normalizedIndex + ), + recordingMode = + when ( + cursor.getString( + recordingIndex + ) + ) { + "continuous" -> + RecordingMode.CONTINUOUS + + else -> + RecordingMode.SETS + }, + trackingMode = + when ( + cursor.getString( + trackingIndex + ) + ) { + "duration" -> + TrackingMode.DURATION + + else -> + TrackingMode.REPS + }, + dataFields = + cursor.getInt( + fieldsIndex + ), + ) + } + } + + return output + } + + fun createExercise( + input: NewExerciseProfile, + ): CreateExerciseResult { + if (!input.validate()) { + return CreateExerciseResult.Invalid + } + + val name = + input.name.trim() + + val normalized = + normalizeName(name) + + if (normalized.isEmpty()) { + return CreateExerciseResult.Invalid + } + + val exercise = + ExerciseProfile( + exerciseId = + "ex_" + + UUID.randomUUID() + .toString(), + name = name, + normalizedName = + normalized, + recordingMode = + input.recordingMode, + trackingMode = + input.trackingMode, + dataFields = + input.dataFields, + ) + + val sql = + """ + INSERT INTO exercises( + exercise_id, + name, + normalized_name, + recording_mode, + tracking_mode, + data_fields + ) VALUES(?, ?, ?, ?, ?, ?); + """.trimIndent() + + return try { + database.writableDatabase.execSQL( + sql, + arrayOf( + exercise.exerciseId, + exercise.name, + exercise.normalizedName, + exercise.recordingMode + .wireValue, + exercise.trackingMode + .wireValue, + exercise.dataFields, + ), + ) + + CreateExerciseResult.Created( + exercise + ) + } catch ( + error: SQLiteConstraintException + ) { + CreateExerciseResult.Conflict + } + } + + fun saveSession( + draft: SessionDraft, + ): SaveSessionResult { + if ( + draft.exercises.isEmpty() || + draft.exercises.any { + !validateSessionExercise(it) + } + ) { + return SaveSessionResult.Invalid + } + + val db = + database.writableDatabase + + val sessionId = + "se_" + + UUID.randomUUID() + .toString() + + val startedAt = + OffsetDateTime.now() + .toString() + + db.beginTransaction() + + return try { + val sessionValues = + ContentValues().apply { + put( + "session_id", + sessionId + ) + + put( + "started_at", + startedAt + ) + + put( + "session_type", + "training" + ) + } + + val sessionRowId = + db.insertOrThrow( + "sessions", + null, + sessionValues + ) + + draft.exercises.forEachIndexed { + exerciseIndex, + exerciseDraft -> + + val exerciseRowId = + lookupExerciseRowId( + db, + exerciseDraft.exercise.exerciseId + ) + + val exerciseValues = + ContentValues().apply { + put( + "session_row_id", + sessionRowId + ) + + put( + "exercise_row_id", + exerciseRowId + ) + + put( + "position", + exerciseIndex + ) + + put( + "recording_mode", + exerciseDraft.exercise + .recordingMode + .wireValue + ) + + put( + "tracking_mode", + exerciseDraft.exercise + .trackingMode + .wireValue + ) + + put( + "data_fields", + exerciseDraft.exercise + .dataFields + ) + } + + val sessionExerciseRowId = + db.insertOrThrow( + "session_exercises", + null, + exerciseValues + ) + + if ( + exerciseDraft.exercise + .recordingMode == + RecordingMode.CONTINUOUS + ) { + val continuousValues = + ContentValues().apply { + put( + "session_exercise_row_id", + sessionExerciseRowId + ) + + put( + "duration_seconds", + exerciseDraft + .continuousDurationSeconds + ) + + exerciseDraft.speedKmh + ?.let { + put( + "speed_kmh", + it + ) + } + + exerciseDraft.distanceKm + ?.let { + put( + "distance_km", + it + ) + } + } + + db.insertOrThrow( + "continuous_activity", + null, + continuousValues + ) + } else { + exerciseDraft.sets + .forEachIndexed { + setIndex, + set -> + + val setValues = + ContentValues().apply { + put( + "session_exercise_row_id", + sessionExerciseRowId + ) + + put( + "position", + setIndex + ) + + if ( + exerciseDraft.exercise + .trackingMode == + TrackingMode.REPS + ) { + put( + "reps", + set.reps + ) + } else { + put( + "duration_seconds", + set.durationSeconds + ) + } + } + + db.insertOrThrow( + "performed_sets", + null, + setValues + ) + } + } + } + + db.setTransactionSuccessful() + + SaveSessionResult.Saved( + sessionId + ) + } catch ( + error: Exception + ) { + SaveSessionResult.DatabaseError + } finally { + db.endTransaction() + } + } + + fun listSessions(): List { + val output = + mutableListOf() + + database.readableDatabase.rawQuery( + """ + SELECT + s.session_id, + s.started_at, + COUNT(se.id) + FROM sessions AS s + LEFT JOIN session_exercises AS se + ON se.session_row_id = s.id + GROUP BY s.id + ORDER BY s.started_at DESC, s.id DESC; + """.trimIndent(), + null, + ).use { cursor -> + while ( + cursor.moveToNext() + ) { + output += + SessionSummary( + sessionId = + cursor.getString(0), + startedAt = + cursor.getString(1), + exerciseCount = + cursor.getInt(2), + ) + } + } + + return output + } + + + + + fun applyPcCatalogJson( + json: String, + ): PcCatalogImportResult { + val root = + try { + JSONObject(json) + } catch ( + error: Exception + ) { + return PcCatalogImportResult.Invalid( + "Catalogue PC JSON invalide." + ) + } + + if ( + root.optString("format") != + "trainlog-pc-catalog" || + root.optInt("version", -1) != + 1 + ) { + return PcCatalogImportResult.Invalid( + "Catalogue PC non supporté." + ) + } + + val exercises = + root.optJSONArray( + "exercises" + ) + ?: return PcCatalogImportResult.Invalid( + "Catalogue PC sans exercices." + ) + + val db = + database.writableDatabase + + var imported = 0 + var reconciled = 0 + var skipped = 0 + + db.beginTransaction() + + return try { + for ( + index in + 0 until exercises.length() + ) { + val item = + exercises + .getJSONObject( + index + ) + + val exerciseId = + item.getString( + "exercise_id" + ) + + val name = + item.getString( + "name" + ) + + val normalized = + normalizeName( + name + ) + + val recording = + when ( + item.getString( + "recording_mode" + ) + ) { + "sets" -> + RecordingMode.SETS + + "continuous" -> + RecordingMode.CONTINUOUS + + else -> + return PcCatalogImportResult.Invalid( + "recording_mode invalide." + ) + } + + val tracking = + when ( + item.getString( + "tracking_mode" + ) + ) { + "reps" -> + TrackingMode.REPS + + "duration" -> + TrackingMode.DURATION + + else -> + return PcCatalogImportResult.Invalid( + "tracking_mode invalide." + ) + } + + val dataFields = + item.getInt( + "data_fields" + ) + + val byId = + findExerciseRow( + db, + "exercise_id = ?", + arrayOf( + exerciseId + ) + ) + + if (byId != null) { + if ( + byId.recordingMode != + recording || + byId.trackingMode != + tracking || + byId.dataFields != + dataFields + ) { + return PcCatalogImportResult.Invalid( + "Conflit de profil catalogue PC." + ) + } + + skipped += 1 + continue + } + + val byName = + findExerciseRow( + db, + "normalized_name = ?", + arrayOf( + normalized + ) + ) + + if (byName != null) { + if ( + byName.recordingMode != + recording || + byName.trackingMode != + tracking || + byName.dataFields != + dataFields + ) { + return PcCatalogImportResult.Invalid( + "Conflit de profil pour $name." + ) + } + + val values = + ContentValues().apply { + put( + "exercise_id", + exerciseId + ) + + put( + "name", + name + ) + } + + db.update( + "exercises", + values, + "id = ?", + arrayOf( + byName.rowId + .toString() + ) + ) + + reconciled += 1 + continue + } + + val values = + ContentValues().apply { + put( + "exercise_id", + exerciseId + ) + + put( + "name", + name + ) + + put( + "normalized_name", + normalized + ) + + put( + "recording_mode", + recording.wireValue + ) + + put( + "tracking_mode", + tracking.wireValue + ) + + put( + "data_fields", + dataFields + ) + } + + db.insertOrThrow( + "exercises", + null, + values + ) + + imported += 1 + } + + db.setTransactionSuccessful() + + PcCatalogImportResult.Applied( + imported = + imported, + reconciled = + reconciled, + skipped = + skipped, + ) + } catch ( + error: Exception + ) { + PcCatalogImportResult.DatabaseError + } finally { + db.endTransaction() + } + } + + private data class ExerciseRow( + val rowId: Long, + val recordingMode: RecordingMode, + val trackingMode: TrackingMode, + val dataFields: Int, + ) + + private fun findExerciseRow( + db: SQLiteDatabase, + selection: String, + arguments: Array, + ): ExerciseRow? { + db.query( + "exercises", + arrayOf( + "id", + "recording_mode", + "tracking_mode", + "data_fields", + ), + selection, + arguments, + null, + null, + null, + ).use { + cursor -> + if ( + !cursor.moveToFirst() + ) { + return null + } + + return ExerciseRow( + rowId = + cursor.getLong(0), + recordingMode = + if ( + cursor.getString(1) == + "continuous" + ) { + RecordingMode.CONTINUOUS + } else { + RecordingMode.SETS + }, + trackingMode = + if ( + cursor.getString(2) == + "duration" + ) { + TrackingMode.DURATION + } else { + TrackingMode.REPS + }, + dataFields = + cursor.getInt(3), + ) + } + } + + fun buildMobileExportJson(): String { + val root = JSONObject() + root.put("format", "trainlog-mobile-export") + root.put("version", 1) + root.put("generated_at", OffsetDateTime.now().toString()) + + val exerciseArray = JSONArray() + listExercises().forEach { exercise -> + exerciseArray.put( + JSONObject() + .put("exercise_id", exercise.exerciseId) + .put("name", exercise.name) + .put("recording_mode", exercise.recordingMode.wireValue) + .put("tracking_mode", exercise.trackingMode.wireValue) + .put("data_fields", exercise.dataFields) + ) + } + root.put("exercises", exerciseArray) + + val db = database.readableDatabase + val sessionArray = JSONArray() + db.rawQuery( + "SELECT id, session_id, started_at, session_type FROM sessions ORDER BY started_at ASC, id ASC;", + null, + ).use { sessions -> + while (sessions.moveToNext()) { + val sessionRowId = sessions.getLong(0) + val session = JSONObject() + .put("session_id", sessions.getString(1)) + .put("started_at", sessions.getString(2)) + .put("session_type", sessions.getString(3)) + val sessionExercises = JSONArray() + db.rawQuery( + "SELECT se.id, e.exercise_id, e.name, se.recording_mode, se.tracking_mode, se.data_fields " + + "FROM session_exercises AS se JOIN exercises AS e ON e.id = se.exercise_row_id " + + "WHERE se.session_row_id = ? ORDER BY se.position ASC;", + arrayOf(sessionRowId.toString()), + ).use { exerciseCursor -> + while (exerciseCursor.moveToNext()) { + val sessionExerciseRowId = exerciseCursor.getLong(0) + val recording = exerciseCursor.getString(3) + val tracking = exerciseCursor.getString(4) + val item = JSONObject() + .put("exercise_id", exerciseCursor.getString(1)) + .put("name", exerciseCursor.getString(2)) + .put("recording_mode", recording) + .put("tracking_mode", tracking) + .put("data_fields", exerciseCursor.getInt(5)) + .put("load_mode", "none") + .put("rest_seconds", 0) + if (recording == "continuous") { + db.query( + "continuous_activity", + arrayOf("duration_seconds", "speed_kmh", "distance_km"), + "session_exercise_row_id = ?", + arrayOf(sessionExerciseRowId.toString()), + null, null, null, + ).use { continuous -> + if (!continuous.moveToFirst()) { + error("Missing continuous activity") + } + val payload = JSONObject() + .put("duration_seconds", continuous.getInt(0)) + if (!continuous.isNull(1)) { + payload.put("speed_kmh", continuous.getDouble(1)) + } + if (!continuous.isNull(2)) { + payload.put("distance_km", continuous.getDouble(2)) + } + item.put("continuous", payload) + } + } else { + val sets = JSONArray() + db.query( + "performed_sets", + arrayOf("reps", "duration_seconds"), + "session_exercise_row_id = ?", + arrayOf(sessionExerciseRowId.toString()), + null, null, "position ASC", + ).use { setCursor -> + while (setCursor.moveToNext()) { + val set = JSONObject() + if (tracking == "reps") { + set.put("reps", setCursor.getInt(0)) + } else { + set.put("duration_seconds", setCursor.getInt(1)) + } + sets.put(set) + } + } + item.put("sets", sets) + } + sessionExercises.put(item) + } + } + session.put("exercises", sessionExercises) + sessionArray.put(session) + } + } + root.put("sessions", sessionArray) + + val bodyArray = JSONArray() + db.rawQuery( + "SELECT observation_id, observed_at, 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 " + + "FROM body_observations ORDER BY observed_at ASC, id ASC;", + null, + ).use { cursor -> + val names = arrayOf( + "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", + ) + while (cursor.moveToNext()) { + val item = JSONObject() + .put("observation_id", cursor.getString(0)) + .put("observed_at", cursor.getString(1)) + for (index in names.indices) { + val column = index + 2 + if (!cursor.isNull(column)) { + item.put(names[index], cursor.getDouble(column)) + } + } + bodyArray.put(item) + } + } + root.put("body_observations", bodyArray) + return root.toString() + } + + fun saveBodyObservation( + draft: BodyObservationDraft, + ): SaveBodyObservationResult { + if ( + !draft.hasAnyMetric() || + !draft.allValuesPositive() + ) { + return SaveBodyObservationResult.Invalid + } + + val observationId = + "bo_" + + UUID.randomUUID() + .toString() + + val observedAt = + OffsetDateTime.now() + .toString() + + val values = + ContentValues().apply { + put( + "observation_id", + observationId + ) + + put( + "observed_at", + observedAt + ) + + putOptionalDouble( + "body_weight_kg", + draft.bodyWeightKg + ) + + putOptionalDouble( + "neck_cm", + draft.neckCm + ) + + putOptionalDouble( + "shoulders_cm", + draft.shouldersCm + ) + + putOptionalDouble( + "chest_cm", + draft.chestCm + ) + + putOptionalDouble( + "waist_cm", + draft.waistCm + ) + + putOptionalDouble( + "hips_cm", + draft.hipsCm + ) + + putOptionalDouble( + "left_arm_cm", + draft.leftArmCm + ) + + putOptionalDouble( + "right_arm_cm", + draft.rightArmCm + ) + + putOptionalDouble( + "left_forearm_cm", + draft.leftForearmCm + ) + + putOptionalDouble( + "right_forearm_cm", + draft.rightForearmCm + ) + + putOptionalDouble( + "left_thigh_cm", + draft.leftThighCm + ) + + putOptionalDouble( + "right_thigh_cm", + draft.rightThighCm + ) + + putOptionalDouble( + "left_calf_cm", + draft.leftCalfCm + ) + + putOptionalDouble( + "right_calf_cm", + draft.rightCalfCm + ) + } + + return try { + database.writableDatabase + .insertOrThrow( + "body_observations", + null, + values + ) + + SaveBodyObservationResult.Saved( + observationId + ) + } catch ( + error: Exception + ) { + SaveBodyObservationResult.DatabaseError + } + } + + fun listBodyObservations( + limit: Int = 20, + ): List { + if (limit <= 0) { + return emptyList() + } + + val output = + mutableListOf< + BodyObservationSummary + >() + + database.readableDatabase.rawQuery( + """ + SELECT + observation_id, + observed_at, + body_weight_kg, + ( + (body_weight_kg IS NOT NULL) + + (neck_cm IS NOT NULL) + + (shoulders_cm IS NOT NULL) + + (chest_cm IS NOT NULL) + + (waist_cm IS NOT NULL) + + (hips_cm IS NOT NULL) + + (left_arm_cm IS NOT NULL) + + (right_arm_cm IS NOT NULL) + + (left_forearm_cm IS NOT NULL) + + (right_forearm_cm IS NOT NULL) + + (left_thigh_cm IS NOT NULL) + + (right_thigh_cm IS NOT NULL) + + (left_calf_cm IS NOT NULL) + + (right_calf_cm IS NOT NULL) + ) AS metric_count + FROM body_observations + ORDER BY observed_at DESC, id DESC + LIMIT ?; + """.trimIndent(), + arrayOf( + limit.toString() + ), + ).use { cursor -> + while ( + cursor.moveToNext() + ) { + output += + BodyObservationSummary( + observationId = + cursor.getString(0), + observedAt = + cursor.getString(1), + bodyWeightKg = + if ( + cursor.isNull(2) + ) { + null + } else { + cursor.getDouble(2) + }, + metricCount = + cursor.getInt(3), + ) + } + } + + return output + } + + fun getSessionDetail( + sessionId: String, + ): SessionDetail? { + val db = + database.readableDatabase + + val summary = + db.rawQuery( + """ + SELECT + s.session_id, + s.started_at, + COUNT(se.id) + FROM sessions AS s + LEFT JOIN session_exercises AS se + ON se.session_row_id = s.id + WHERE s.session_id = ? + GROUP BY s.id; + """.trimIndent(), + arrayOf(sessionId), + ).use { cursor -> + if (!cursor.moveToFirst()) { + null + } else { + SessionSummary( + sessionId = + cursor.getString(0), + startedAt = + cursor.getString(1), + exerciseCount = + cursor.getInt(2), + ) + } + } ?: return null + + val exercises = + mutableListOf< + SessionExerciseDetail + >() + + db.rawQuery( + """ + SELECT + se.id, + e.name, + se.recording_mode, + se.tracking_mode, + se.data_fields + FROM session_exercises AS se + JOIN sessions AS s + ON s.id = se.session_row_id + JOIN exercises AS e + ON e.id = se.exercise_row_id + WHERE s.session_id = ? + ORDER BY se.position ASC; + """.trimIndent(), + arrayOf(sessionId), + ).use { cursor -> + while (cursor.moveToNext()) { + val sessionExerciseRowId = + cursor.getLong(0) + + val name = + cursor.getString(1) + + val recording = + when ( + cursor.getString(2) + ) { + "continuous" -> + RecordingMode.CONTINUOUS + + else -> + RecordingMode.SETS + } + + val tracking = + when ( + cursor.getString(3) + ) { + "duration" -> + TrackingMode.DURATION + + else -> + TrackingMode.REPS + } + + val dataFields = + cursor.getInt(4) + + if ( + recording == + RecordingMode.CONTINUOUS + ) { + db.query( + "continuous_activity", + arrayOf( + "duration_seconds", + "speed_kmh", + "distance_km", + ), + "session_exercise_row_id = ?", + arrayOf( + sessionExerciseRowId + .toString() + ), + null, + null, + null, + ).use { + continuous -> + + if ( + !continuous + .moveToFirst() + ) { + error( + "Missing continuous activity" + ) + } + + exercises += + SessionExerciseDetail( + exerciseName = + name, + recordingMode = + recording, + trackingMode = + tracking, + dataFields = + dataFields, + continuousDurationSeconds = + continuous + .getInt(0), + speedKmh = + if ( + continuous + .isNull(1) + ) { + null + } else { + continuous + .getDouble(1) + }, + distanceKm = + if ( + continuous + .isNull(2) + ) { + null + } else { + continuous + .getDouble(2) + }, + ) + } + } else { + val sets = + mutableListOf< + SessionSetDraft + >() + + db.query( + "performed_sets", + arrayOf( + "reps", + "duration_seconds", + ), + "session_exercise_row_id = ?", + arrayOf( + sessionExerciseRowId + .toString() + ), + null, + null, + "position ASC", + ).use { setCursor -> + while ( + setCursor + .moveToNext() + ) { + sets += + SessionSetDraft( + reps = + if ( + setCursor + .isNull(0) + ) { + 0 + } else { + setCursor + .getInt(0) + }, + durationSeconds = + if ( + setCursor + .isNull(1) + ) { + 0 + } else { + setCursor + .getInt(1) + }, + ) + } + } + + exercises += + SessionExerciseDetail( + exerciseName = + name, + recordingMode = + recording, + trackingMode = + tracking, + dataFields = + dataFields, + sets = sets, + ) + } + } + } + + return SessionDetail( + summary = summary, + exercises = exercises, + ) + } + + private fun validateSessionExercise( + draft: SessionExerciseDraft, + ): Boolean { + return when ( + draft.exercise.recordingMode + ) { + RecordingMode.CONTINUOUS -> { + if ( + draft.continuousDurationSeconds <= 0 || + draft.sets.isNotEmpty() + ) { + false + } else { + val wantsSpeed = + draft.exercise.dataFields and + 1 != 0 + + val wantsDistance = + draft.exercise.dataFields and + 2 != 0 + + ( + (wantsSpeed == + (draft.speedKmh != null)) && + ( + !wantsSpeed || + draft.speedKmh!! > 0.0 + ) && + (wantsDistance == + (draft.distanceKm != null)) && + ( + !wantsDistance || + draft.distanceKm!! > 0.0 + ) + ) + } + } + + RecordingMode.SETS -> { + if ( + draft.sets.isEmpty() || + draft.continuousDurationSeconds != 0 || + draft.speedKmh != null || + draft.distanceKm != null + ) { + false + } else { + when ( + draft.exercise.trackingMode + ) { + TrackingMode.REPS -> + draft.sets.all { + it.reps >= 0 && + it.durationSeconds == 0 + } + + TrackingMode.DURATION -> + draft.sets.all { + it.durationSeconds > 0 && + it.reps == 0 + } + } + } + } + } + } + + private fun lookupExerciseRowId( + db: SQLiteDatabase, + exerciseId: String, + ): Long { + db.query( + "exercises", + arrayOf("id"), + "exercise_id = ?", + arrayOf(exerciseId), + null, + null, + null, + ).use { cursor -> + if (!cursor.moveToFirst()) { + error( + "Exercise not found: $exerciseId" + ) + } + + return cursor.getLong(0) + } + } + + private fun normalizeName( + value: String, + ): String { + val decomposed = + Normalizer.normalize( + value.trim() + .lowercase( + Locale.ROOT + ), + Normalizer.Form.NFD, + ) + + return decomposed + .replace( + Regex("\\p{Mn}+"), + "", + ) + .replace( + Regex("\\s+"), + " ", + ) + .trim() + } +} + + +private fun ContentValues.putOptionalDouble( + key: String, + value: Double?, +) { + if (value == null) { + putNull(key) + } else { + put(key, value) + } +} + +private class TrainlogDatabaseHelper( + context: Context, +) : SQLiteOpenHelper( + context, + "trainlog-android.db", + null, + 3, +) { + override fun onConfigure( + db: SQLiteDatabase, + ) { + super.onConfigure(db) + + db.setForeignKeyConstraintsEnabled( + true + ) + } + + override fun onCreate( + db: SQLiteDatabase, + ) { + createExerciseTable(db) + createSessionTables(db) + createBodyTable(db) + } + + override fun onUpgrade( + db: SQLiteDatabase, + oldVersion: Int, + newVersion: Int, + ) { + var version = oldVersion + + if (version < 2 && newVersion >= 2) { + createSessionTables(db) + version = 2 + } + + if (version < 3 && newVersion >= 3) { + createBodyTable(db) + version = 3 + } + + if (version != newVersion) { + error( + "Unsupported Android DB upgrade " + + "$oldVersion -> $newVersion" + ) + } + } + + private fun createExerciseTable( + db: SQLiteDatabase, + ) { + db.execSQL( + """ + CREATE TABLE exercises( + id INTEGER PRIMARY KEY, + exercise_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL UNIQUE, + recording_mode TEXT NOT NULL + CHECK( + recording_mode IN ( + 'sets', + 'continuous' + ) + ), + tracking_mode TEXT NOT NULL + CHECK( + tracking_mode IN ( + 'reps', + 'duration' + ) + ), + data_fields INTEGER NOT NULL + DEFAULT 0 + CHECK( + data_fields >= 0 AND + (data_fields & ~3) = 0 + ), + CHECK( + recording_mode != + 'continuous' OR + tracking_mode = + 'duration' + ), + CHECK( + recording_mode != + 'sets' OR + data_fields = 0 + ) + ); + """.trimIndent() + ) + } + + private fun createSessionTables( + db: SQLiteDatabase, + ) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS sessions( + id INTEGER PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + started_at TEXT NOT NULL, + session_type TEXT NOT NULL + CHECK( + session_type IN ( + 'training', + 'max_test' + ) + ) + ); + """.trimIndent() + ) + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS session_exercises( + id INTEGER PRIMARY KEY, + session_row_id INTEGER NOT NULL + REFERENCES sessions(id) + ON DELETE CASCADE, + exercise_row_id INTEGER NOT NULL + REFERENCES exercises(id) + ON DELETE RESTRICT, + position INTEGER NOT NULL + CHECK(position >= 0), + recording_mode TEXT NOT NULL + CHECK( + recording_mode IN ( + 'sets', + 'continuous' + ) + ), + tracking_mode TEXT NOT NULL + CHECK( + tracking_mode IN ( + 'reps', + 'duration' + ) + ), + data_fields INTEGER NOT NULL + CHECK( + data_fields >= 0 AND + (data_fields & ~3) = 0 + ), + UNIQUE( + session_row_id, + position + ) + ); + """.trimIndent() + ) + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS performed_sets( + id INTEGER PRIMARY KEY, + session_exercise_row_id INTEGER NOT NULL + REFERENCES session_exercises(id) + ON DELETE CASCADE, + position INTEGER NOT NULL + CHECK(position >= 0), + reps INTEGER + CHECK(reps >= 0), + duration_seconds INTEGER + CHECK(duration_seconds > 0), + CHECK( + ( + reps IS NOT NULL AND + duration_seconds IS NULL + ) OR ( + reps IS NULL AND + duration_seconds IS NOT NULL + ) + ), + UNIQUE( + session_exercise_row_id, + position + ) + ); + """.trimIndent() + ) + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS continuous_activity( + id INTEGER PRIMARY KEY, + session_exercise_row_id INTEGER NOT NULL UNIQUE + REFERENCES session_exercises(id) + ON DELETE CASCADE, + duration_seconds INTEGER NOT NULL + CHECK(duration_seconds > 0), + speed_kmh REAL + CHECK(speed_kmh > 0.0), + distance_km REAL + CHECK(distance_km > 0.0) + ); + """.trimIndent() + ) + } + + + private fun createBodyTable( + db: SQLiteDatabase, + ) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS body_observations( + id INTEGER PRIMARY KEY, + observation_id TEXT NOT NULL UNIQUE, + observed_at TEXT NOT NULL, + body_weight_kg REAL + CHECK(body_weight_kg > 0.0), + neck_cm REAL + CHECK(neck_cm > 0.0), + shoulders_cm REAL + CHECK(shoulders_cm > 0.0), + chest_cm REAL + CHECK(chest_cm > 0.0), + waist_cm REAL + CHECK(waist_cm > 0.0), + hips_cm REAL + CHECK(hips_cm > 0.0), + left_arm_cm REAL + CHECK(left_arm_cm > 0.0), + right_arm_cm REAL + CHECK(right_arm_cm > 0.0), + left_forearm_cm REAL + CHECK(left_forearm_cm > 0.0), + right_forearm_cm REAL + CHECK(right_forearm_cm > 0.0), + left_thigh_cm REAL + CHECK(left_thigh_cm > 0.0), + right_thigh_cm REAL + CHECK(right_thigh_cm > 0.0), + left_calf_cm REAL + CHECK(left_calf_cm > 0.0), + right_calf_cm REAL + CHECK(right_calf_cm > 0.0), + CHECK( + body_weight_kg IS NOT NULL OR + neck_cm IS NOT NULL OR + shoulders_cm IS NOT NULL OR + chest_cm IS NOT NULL OR + waist_cm IS NOT NULL OR + hips_cm IS NOT NULL OR + left_arm_cm IS NOT NULL OR + right_arm_cm IS NOT NULL OR + left_forearm_cm IS NOT NULL OR + right_forearm_cm IS NOT NULL OR + left_thigh_cm IS NOT NULL OR + right_thigh_cm IS NOT NULL OR + left_calf_cm IS NOT NULL OR + right_calf_cm IS NOT NULL + ) + ); + """.trimIndent() + ) + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt index 8cf747b..eee9c40 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt @@ -1,6 +1,6 @@ package com.labfytools.trainlog.ui -/* TRAINLOG_SYNC_AUTO_APPLY */ +/* TRAINLOG_ANDROID_TRIGGERED_SYNC_V1 */ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -12,9 +12,11 @@ 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.SyncReceiptResult import com.labfytools.trainlog.data.SyncRequestOutbox import com.labfytools.trainlog.data.SyncRequestResult import com.labfytools.trainlog.ui.theme.LocalTrainlogColors +import kotlinx.coroutines.delay @Composable fun SyncScreen( @@ -45,6 +47,13 @@ fun SyncScreen( ) } + var pendingRequestId by + remember { + mutableStateOf( + null + ) + } + LaunchedEffect(Unit) { when ( val result = @@ -76,6 +85,124 @@ fun SyncScreen( } } + LaunchedEffect( + pendingRequestId + ) { + val requestId = + pendingRequestId + ?: return@LaunchedEffect + + repeat(60) { + when ( + val receipt = + inbox.readSyncReceipt( + requestId + ) + ) { + SyncReceiptResult.Pending -> { + delay(1000) + } + + SyncReceiptResult.FolderNotAuthorized -> { + success = false + + status = + "Dossier Trainlog non autorisé." + + pendingRequestId = + null + + return@LaunchedEffect + } + + is SyncReceiptResult.Error -> { + success = false + status = + receipt.message + + pendingRequestId = + null + + return@LaunchedEffect + } + + is SyncReceiptResult.Received -> { + if (!receipt.success) { + success = false + + status = + receipt.summary + + pendingRequestId = + null + + return@LaunchedEffect + } + + when ( + val catalog = + inbox.importPcCatalog() + ) { + is CatalogInboxResult.Imported -> { + success = true + + status = + ( + "Synchronisation terminée · " + + receipt.summary + + " · Android catalogue : " + + "${catalog.imported} nouveau(x), " + + "${catalog.reconciled} réconcilié(s), " + + "${catalog.skipped} présent(s)." + ) + + onCatalogChanged() + } + + CatalogInboxResult.FileNotFound -> { + success = false + + status = + ( + "Sync PC terminée, mais catalogue reçu introuvable." + ) + } + + CatalogInboxResult.FolderNotAuthorized -> { + success = false + + status = + "Sync PC terminée, dossier Trainlog non autorisé." + } + + is CatalogInboxResult.Error -> { + success = false + + status = + ( + "Sync PC terminée, import Android : " + + catalog.message + ) + } + } + + pendingRequestId = + null + + return@LaunchedEffect + } + } + } + + success = false + + status = + "Le PC n'a pas répondu dans les 60 secondes." + + pendingRequestId = + null + } + val folderLauncher = rememberLauncherForActivityResult( contract = @@ -122,13 +249,14 @@ fun SyncScreen( CatalogInboxResult.FileNotFound -> { success = true + status = "Dossier autorisé · aucun catalogue PC reçu." } CatalogInboxResult.FolderNotAuthorized, is CatalogInboxResult.Error -> { - /* Keep the permission status already shown. */ + /* Keep permission state. */ } } } @@ -151,18 +279,43 @@ fun SyncScreen( title = "SYNCHRONISER" ) { TrainlogInfo( - "Le snapshot Android est maintenu automatiquement.", - color = colors.accent, + text = + "Le snapshot Android est maintenu automatiquement.", + color = + colors.accent, ) TrainlogAction( label = - "Synchroniser maintenant", + if ( + pendingRequestId != + null + ) { + "Synchronisation en cours..." + } else { + "Synchroniser maintenant" + }, description = - "Envoie une demande au service Trainlog du PC.", + "Android → PC puis PC → Android, en une seule opération.", accent = colors.success, onClick = { + if ( + pendingRequestId != + null + ) { + return@TrainlogAction + } + + if (!folderAuthorized) { + success = false + + status = + "Autorisez d'abord Téléchargements/Trainlog." + + return@TrainlogAction + } + when ( val result = requestOutbox @@ -171,11 +324,11 @@ fun SyncScreen( is SyncRequestResult.Requested -> { success = true + pendingRequestId = + result.requestId + status = - ( - "Demande envoyée : " + - result.requestId - ) + "Demande envoyée · attente du PC..." } SyncRequestResult.Unsupported -> { @@ -198,11 +351,11 @@ fun SyncScreen( TrainlogFrame( title = - "CATALOGUE PC → ANDROID" + "DOSSIER D'ECHANGE" ) { if (folderAuthorized) { TrainlogInfo( - "Dossier Trainlog autorisé.", + "Téléchargements/Trainlog autorisé.", color = colors.success, ) @@ -228,14 +381,13 @@ fun SyncScreen( TrainlogAction( label = - "Appliquer le dernier catalogue PC", + "Relire le catalogue PC", description = - "Réconcilie les exercices publiés par le PC.", + "Action de récupération manuelle si nécessaire.", onClick = { when ( val result = - inbox - .importPcCatalog() + inbox.importPcCatalog() ) { is CatalogInboxResult.Imported -> { success = true @@ -245,7 +397,7 @@ fun SyncScreen( "Catalogue PC : " + "${result.imported} nouveau(x), " + "${result.reconciled} réconcilié(s), " + - "${result.skipped} déjà présent(s)." + "${result.skipped} présent(s)." ) onCatalogChanged() @@ -255,7 +407,7 @@ fun SyncScreen( success = false status = - "Autorisez d'abord Download/Trainlog." + "Autorisez d'abord Téléchargements/Trainlog." } CatalogInboxResult.FileNotFound -> { diff --git a/docs/android.md b/docs/android.md index fc277d1..f1c8342 100644 --- a/docs/android.md +++ b/docs/android.md @@ -756,3 +756,116 @@ The database replacement remains transactional. `TRAINLOG_FORMAT_V1` remains frozen and unchanged. + + +## Shared bidirectional synchronization v1 + +Validated architecture: + +```text +Android local write + -> automatic mobile snapshot + +Android "Synchroniser maintenant" + -> trainlog-sync-request-v1.json + +trainlog-syncd + -> shared C synchronization engine + -> Android → PC mobile import + -> PC → Android catalog publish + -> trainlog-sync-receipt-v1.json + +Android + -> receipt matched by request_id + -> PC catalog applied locally + -> final result displayed +``` + +The ncurses TUI and `trainlog-syncd` call the same +`trainlog_sync_run()` implementation. + +Direct libmtp remains mandatory. No filesystem mount and no SQLite-file +synchronization are introduced. + +### Concurrency + +The shared engine owns: + +```text +$XDG_DATA_HOME/trainlog/sync.lock +``` + +A TUI-triggered transaction waits for the lock. Daemon request polling is +non-blocking and retries later. + +### Sync history + +Every actual synchronization transaction creates: + +```text +$XDG_DATA_HOME/trainlog/sync_runs/sy_*.json +$XDG_DATA_HOME/trainlog/sync_runs/sy_*.txt +``` + +and appends a compact entry to: + +```text +$XDG_DATA_HOME/trainlog/sync_history.log +``` + +The TUI behaves like: + +```text +git log + ↑/↓ select synchronization + +git show + Enter opens structured detail +``` + +Legacy three-field history entries remain readable but have no structured +detail file. + +### Android request and receipt + +Request: + +```text +format = trainlog-sync-request +version = 1 +``` + +Receipt: + +```text +format = trainlog-sync-receipt +version = 1 +``` + +The receipt carries the originating `request_id`, a generated `sync_id`, +status, summary and synchronization counts. Android ignores a receipt for a +different request ID. + +### User service + +Install/refresh the user service with: + +```text +bash tools/install_syncd_user.sh +``` + +No root privilege is required. + +### Status + +```text +COMMON_SYNC_ENGINE=PASS +TUI_SYNC_LOG_SHOW=PASS +TRAINLOG_SYNCD=PASS +ANDROID_TRIGGERED_SYNC=PASS +ANDROID_SYNC_RECEIPT=PASS +BIDIRECTIONAL_SYNC_V1=PASS +``` + +Frozen `TRAINLOG_FORMAT_V1` remains unchanged. + diff --git a/docs/roadmap.md b/docs/roadmap.md index f2debb0..82b8e12 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -584,3 +584,116 @@ The database replacement remains transactional. `TRAINLOG_FORMAT_V1` remains frozen and unchanged. + + +## Shared bidirectional synchronization v1 + +Validated architecture: + +```text +Android local write + -> automatic mobile snapshot + +Android "Synchroniser maintenant" + -> trainlog-sync-request-v1.json + +trainlog-syncd + -> shared C synchronization engine + -> Android → PC mobile import + -> PC → Android catalog publish + -> trainlog-sync-receipt-v1.json + +Android + -> receipt matched by request_id + -> PC catalog applied locally + -> final result displayed +``` + +The ncurses TUI and `trainlog-syncd` call the same +`trainlog_sync_run()` implementation. + +Direct libmtp remains mandatory. No filesystem mount and no SQLite-file +synchronization are introduced. + +### Concurrency + +The shared engine owns: + +```text +$XDG_DATA_HOME/trainlog/sync.lock +``` + +A TUI-triggered transaction waits for the lock. Daemon request polling is +non-blocking and retries later. + +### Sync history + +Every actual synchronization transaction creates: + +```text +$XDG_DATA_HOME/trainlog/sync_runs/sy_*.json +$XDG_DATA_HOME/trainlog/sync_runs/sy_*.txt +``` + +and appends a compact entry to: + +```text +$XDG_DATA_HOME/trainlog/sync_history.log +``` + +The TUI behaves like: + +```text +git log + ↑/↓ select synchronization + +git show + Enter opens structured detail +``` + +Legacy three-field history entries remain readable but have no structured +detail file. + +### Android request and receipt + +Request: + +```text +format = trainlog-sync-request +version = 1 +``` + +Receipt: + +```text +format = trainlog-sync-receipt +version = 1 +``` + +The receipt carries the originating `request_id`, a generated `sync_id`, +status, summary and synchronization counts. Android ignores a receipt for a +different request ID. + +### User service + +Install/refresh the user service with: + +```text +bash tools/install_syncd_user.sh +``` + +No root privilege is required. + +### Status + +```text +COMMON_SYNC_ENGINE=PASS +TUI_SYNC_LOG_SHOW=PASS +TRAINLOG_SYNCD=PASS +ANDROID_TRIGGERED_SYNC=PASS +ANDROID_SYNC_RECEIPT=PASS +BIDIRECTIONAL_SYNC_V1=PASS +``` + +Frozen `TRAINLOG_FORMAT_V1` remains unchanged. + diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 0883108..a1a0009 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -363,3 +363,116 @@ The database replacement remains transactional. `TRAINLOG_FORMAT_V1` remains frozen and unchanged. + + +## Shared bidirectional synchronization v1 + +Validated architecture: + +```text +Android local write + -> automatic mobile snapshot + +Android "Synchroniser maintenant" + -> trainlog-sync-request-v1.json + +trainlog-syncd + -> shared C synchronization engine + -> Android → PC mobile import + -> PC → Android catalog publish + -> trainlog-sync-receipt-v1.json + +Android + -> receipt matched by request_id + -> PC catalog applied locally + -> final result displayed +``` + +The ncurses TUI and `trainlog-syncd` call the same +`trainlog_sync_run()` implementation. + +Direct libmtp remains mandatory. No filesystem mount and no SQLite-file +synchronization are introduced. + +### Concurrency + +The shared engine owns: + +```text +$XDG_DATA_HOME/trainlog/sync.lock +``` + +A TUI-triggered transaction waits for the lock. Daemon request polling is +non-blocking and retries later. + +### Sync history + +Every actual synchronization transaction creates: + +```text +$XDG_DATA_HOME/trainlog/sync_runs/sy_*.json +$XDG_DATA_HOME/trainlog/sync_runs/sy_*.txt +``` + +and appends a compact entry to: + +```text +$XDG_DATA_HOME/trainlog/sync_history.log +``` + +The TUI behaves like: + +```text +git log + ↑/↓ select synchronization + +git show + Enter opens structured detail +``` + +Legacy three-field history entries remain readable but have no structured +detail file. + +### Android request and receipt + +Request: + +```text +format = trainlog-sync-request +version = 1 +``` + +Receipt: + +```text +format = trainlog-sync-receipt +version = 1 +``` + +The receipt carries the originating `request_id`, a generated `sync_id`, +status, summary and synchronization counts. Android ignores a receipt for a +different request ID. + +### User service + +Install/refresh the user service with: + +```text +bash tools/install_syncd_user.sh +``` + +No root privilege is required. + +### Status + +```text +COMMON_SYNC_ENGINE=PASS +TUI_SYNC_LOG_SHOW=PASS +TRAINLOG_SYNCD=PASS +ANDROID_TRIGGERED_SYNC=PASS +ANDROID_SYNC_RECEIPT=PASS +BIDIRECTIONAL_SYNC_V1=PASS +``` + +Frozen `TRAINLOG_FORMAT_V1` remains unchanged. + diff --git a/docs/tui.md b/docs/tui.md index 82b8c07..95cd7fb 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -884,3 +884,116 @@ The database replacement remains transactional. `TRAINLOG_FORMAT_V1` remains frozen and unchanged. + + +## Shared bidirectional synchronization v1 + +Validated architecture: + +```text +Android local write + -> automatic mobile snapshot + +Android "Synchroniser maintenant" + -> trainlog-sync-request-v1.json + +trainlog-syncd + -> shared C synchronization engine + -> Android → PC mobile import + -> PC → Android catalog publish + -> trainlog-sync-receipt-v1.json + +Android + -> receipt matched by request_id + -> PC catalog applied locally + -> final result displayed +``` + +The ncurses TUI and `trainlog-syncd` call the same +`trainlog_sync_run()` implementation. + +Direct libmtp remains mandatory. No filesystem mount and no SQLite-file +synchronization are introduced. + +### Concurrency + +The shared engine owns: + +```text +$XDG_DATA_HOME/trainlog/sync.lock +``` + +A TUI-triggered transaction waits for the lock. Daemon request polling is +non-blocking and retries later. + +### Sync history + +Every actual synchronization transaction creates: + +```text +$XDG_DATA_HOME/trainlog/sync_runs/sy_*.json +$XDG_DATA_HOME/trainlog/sync_runs/sy_*.txt +``` + +and appends a compact entry to: + +```text +$XDG_DATA_HOME/trainlog/sync_history.log +``` + +The TUI behaves like: + +```text +git log + ↑/↓ select synchronization + +git show + Enter opens structured detail +``` + +Legacy three-field history entries remain readable but have no structured +detail file. + +### Android request and receipt + +Request: + +```text +format = trainlog-sync-request +version = 1 +``` + +Receipt: + +```text +format = trainlog-sync-receipt +version = 1 +``` + +The receipt carries the originating `request_id`, a generated `sync_id`, +status, summary and synchronization counts. Android ignores a receipt for a +different request ID. + +### User service + +Install/refresh the user service with: + +```text +bash tools/install_syncd_user.sh +``` + +No root privilege is required. + +### Status + +```text +COMMON_SYNC_ENGINE=PASS +TUI_SYNC_LOG_SHOW=PASS +TRAINLOG_SYNCD=PASS +ANDROID_TRIGGERED_SYNC=PASS +ANDROID_SYNC_RECEIPT=PASS +BIDIRECTIONAL_SYNC_V1=PASS +``` + +Frozen `TRAINLOG_FORMAT_V1` remains unchanged. + diff --git a/tools/install_syncd_user.sh b/tools/install_syncd_user.sh new file mode 100755 index 0000000..69ef2b0 --- /dev/null +++ b/tools/install_syncd_user.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$( + CDPATH= cd -- "$(dirname -- "$0")/.." && + pwd +)" + +SYNC_ONCE="$ROOT/build/tui/trainlog-sync-once" +DAEMON="$ROOT/tools/trainlog_syncd.py" + +if [[ ! -x "$SYNC_ONCE" ]]; then + echo "missing executable: $SYNC_ONCE" >&2 + echo "run meson compile -C build first" >&2 + exit 1 +fi + +mkdir -p \ + "$HOME/.local/bin" \ + "$HOME/.config/systemd/user" + +ln -sfn \ + "$SYNC_ONCE" \ + "$HOME/.local/bin/trainlog-sync-once" + +ln -sfn \ + "$DAEMON" \ + "$HOME/.local/bin/trainlog-syncd" + +UNIT="$HOME/.config/systemd/user/trainlog-syncd.service" + +cat > "$UNIT" < None: + global STOP + STOP = True + + +def append_log( + text: str, +) -> None: + state_home = Path.home() / ".local" / "state" / "trainlog" + state_home.mkdir( + parents=True, + exist_ok=True, + ) + + with ( + state_home / "syncd.log" + ).open( + "a", + encoding="utf-8", + ) as handle: + handle.write( + time.strftime( + "%Y-%m-%d %H:%M:%S " + ) + ) + + handle.write(text.rstrip()) + handle.write("\n") + + +def main() -> int: + parser = argparse.ArgumentParser() + + parser.add_argument( + "--sync-once", + required=True, + type=Path, + ) + + parser.add_argument( + "--interval", + type=float, + default=3.0, + ) + + args = parser.parse_args() + + if not args.sync_once.exists(): + raise SystemExit( + f"sync executable missing: {args.sync_once}" + ) + + signal.signal( + signal.SIGTERM, + request_stop, + ) + + signal.signal( + signal.SIGINT, + request_stop, + ) + + append_log( + "trainlog-syncd started" + ) + + while not STOP: + result = subprocess.run( + [ + str(args.sync_once), + "--request-only", + "--trigger", + "android", + ], + capture_output=True, + text=True, + check=False, + ) + + if result.returncode == 0: + append_log( + result.stdout + ) + elif result.returncode not in ( + 3, + ): + detail = ( + result.stderr.strip() + or result.stdout.strip() + or f"exit={result.returncode}" + ) + + append_log( + detail + ) + + deadline = ( + time.monotonic() + + max( + args.interval, + 1.0, + ) + ) + + while ( + not STOP + and time.monotonic() + < deadline + ): + time.sleep(0.2) + + append_log( + "trainlog-syncd stopped" + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tui/include/trainlog/sync.h b/tui/include/trainlog/sync.h new file mode 100644 index 0000000..11940ea --- /dev/null +++ b/tui/include/trainlog/sync.h @@ -0,0 +1,91 @@ +#ifndef TRAINLOG_SYNC_H +#define TRAINLOG_SYNC_H + +/** + * @file sync.h + * @brief Shared bidirectional Android/desktop synchronization engine. + */ + +#include +#include + +#include "trainlog/model.h" +#include "trainlog/mtp.h" +#include "trainlog/status.h" +#include "trainlog/usb.h" + +#define TRAINLOG_SYNC_SUMMARY_MAX 255U +#define TRAINLOG_SYNC_ERROR_MAX 511U + +typedef enum TrainlogSyncTrigger { + TRAINLOG_SYNC_TRIGGER_TUI = 0, + TRAINLOG_SYNC_TRIGGER_ANDROID, + TRAINLOG_SYNC_TRIGGER_DAEMON +} TrainlogSyncTrigger; + +typedef struct TrainlogSyncDeviceInfo { + bool connected; + bool storage_ready; + TrainlogUsbDevice device; + TrainlogMtpStorage storage; +} TrainlogSyncDeviceInfo; + +typedef struct TrainlogSyncReport { + bool success; + bool request_present; + + char sync_id[ + TRAINLOG_ID_MAX + 1U + ]; + + char request_id[ + TRAINLOG_ID_MAX + 1U + ]; + + char started_at[ + TRAINLOG_TIMESTAMP_MAX + 1U + ]; + + 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; + + char summary[ + TRAINLOG_SYNC_SUMMARY_MAX + 1U + ]; + + char error[ + TRAINLOG_SYNC_ERROR_MAX + 1U + ]; +} TrainlogSyncReport; + +/** + * @brief Probe one currently connected MTP device without mutating it. + */ +TrainlogStatus trainlog_sync_probe( + TrainlogSyncDeviceInfo *output +); + +/** + * @brief Run one complete bidirectional synchronization transaction. + * + * When require_request is true, the transaction runs only if Android has + * published a new trainlog-sync-request-v1 request ID. + * + * The engine is shared by the ncurses TUI and trainlog-syncd. + */ +TrainlogStatus trainlog_sync_run( + TrainlogSyncTrigger trigger, + bool require_request, + TrainlogSyncReport *output +); + +#endif diff --git a/tui/meson.build b/tui/meson.build index fe074d3..7ffa258 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -36,6 +36,7 @@ trainlog_core_sources = files( 'src/usb.c', 'src/mtp.c', 'src/reps.c', + 'src/sync.c', ) trainlog_core = static_library( @@ -333,3 +334,10 @@ test( meson.project_source_root() / 'tests/test_mobile_import_variable_sets.py', ], ) + +trainlog_sync_once = executable( + 'trainlog-sync-once', + 'tools/sync_once.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) diff --git a/tui/src/id.c b/tui/src/id.c index 7cb11f9..c1a96f8 100644 --- a/tui/src/id.c +++ b/tui/src/id.c @@ -20,7 +20,8 @@ static bool prefix_is_supported(const char *prefix) */ return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || - strcmp(prefix, "bo") == 0; + strcmp(prefix, "bo") == 0 || + strcmp(prefix, "sy") == 0; } TrainlogStatus trainlog_id_generate( diff --git a/tui/src/sync.c b/tui/src/sync.c new file mode 100644 index 0000000..9ad774c --- /dev/null +++ b/tui/src/sync.c @@ -0,0 +1,2634 @@ +/** + * @file sync.c + * @brief Shared direct-MTP Trainlog synchronization engine. + */ + +#include "trainlog/sync.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "trainlog/id.h" +#include "trainlog/timeutil.h" + +#define SYNC_DEVICE_CAPACITY 8U +#define SYNC_STORAGE_CAPACITY 8U +#define SYNC_ENTRY_CAPACITY 256U +#define SYNC_TOOL_OUTPUT_MAX 4095U +#define SYNC_REQUEST_TEXT_MAX 4095U + +static const char *const MOBILE_EXPORT_NAME = + "trainlog-mobile-export-v1.json"; + +static const char *const PC_CATALOG_NAME = + "trainlog-pc-catalog-v1.json"; + +static const char *const SYNC_REQUEST_NAME = + "trainlog-sync-request-v1.json"; + +static const char *const SYNC_RECEIPT_NAME = + "trainlog-sync-receipt-v1.json"; + +static const char *const MOBILE_EXPORT_LOCAL = + "/tmp/trainlog-mobile-export-v1.json"; + +static const char *const PC_CATALOG_LOCAL = + "/tmp/trainlog-pc-catalog-v1.json"; + +static const char *const SYNC_REQUEST_LOCAL = + "/tmp/trainlog-sync-request-v1.json"; + +static const char *const SYNC_RECEIPT_LOCAL = + "/tmp/trainlog-sync-receipt-v1.json"; + +static const char *const MOBILE_IMPORT_RESULT = + "/tmp/trainlog-mobile-import-result.txt"; + +static const char *const PC_CATALOG_RESULT = + "/tmp/trainlog-pc-catalog-result.txt"; + +typedef struct SyncSilence { + int saved_stdout; + int saved_stderr; + int null_fd; +} SyncSilence; + +static bool ensure_directory( + const char *path +) +{ + if ( + path == NULL || + path[0] == '\0' + ) { + return false; + } + + if ( + mkdir( + path, + 0700 + ) == 0 + ) { + return true; + } + + return errno == EEXIST; +} + +static bool sync_data_root( + 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", + data_home + ); + } else if ( + home != NULL && + home[0] != '\0' + ) { + char local_dir[ + PATH_MAX + 1U + ]; + + char share_dir[ + PATH_MAX + 1U + ]; + + written = + snprintf( + local_dir, + sizeof(local_dir), + "%s/.local", + home + ); + + if ( + written < 0 || + (size_t)written >= + sizeof(local_dir) || + !ensure_directory( + local_dir + ) + ) { + return false; + } + + written = + snprintf( + share_dir, + sizeof(share_dir), + "%s/share", + local_dir + ); + + if ( + written < 0 || + (size_t)written >= + sizeof(share_dir) || + !ensure_directory( + share_dir + ) + ) { + return false; + } + + written = + snprintf( + output, + output_size, + "%s/trainlog", + share_dir + ); + } else { + return false; + } + + if ( + written < 0 || + (size_t)written >= + output_size + ) { + return false; + } + + return + ensure_directory( + output + ); +} + +static bool sync_runs_directory( + char *output, + size_t output_size +) +{ + char root[ + PATH_MAX + 1U + ]; + + int written; + + if ( + !sync_data_root( + root, + sizeof(root) + ) + ) { + return false; + } + + written = + snprintf( + output, + output_size, + "%s/sync_runs", + root + ); + + if ( + written < 0 || + (size_t)written >= + output_size + ) { + return false; + } + + return + ensure_directory( + output + ); +} + +static bool sync_data_file( + const char *name, + char *output, + size_t output_size +) +{ + char root[ + PATH_MAX + 1U + ]; + + int written; + + if ( + name == NULL || + !sync_data_root( + root, + sizeof(root) + ) + ) { + return false; + } + + written = + snprintf( + output, + output_size, + "%s/%s", + root, + name + ); + + return + written >= 0 && + (size_t)written < + output_size; +} + +static int sync_lock_open( + bool blocking +) +{ + char path[ + PATH_MAX + 1U + ]; + + int descriptor; + int operation = + LOCK_EX; + + if ( + !sync_data_file( + "sync.lock", + path, + sizeof(path) + ) + ) { + return -1; + } + + descriptor = + open( + path, + O_RDWR | + O_CREAT | + O_CLOEXEC, + 0600 + ); + + if (descriptor < 0) { + return -1; + } + + if (!blocking) { + operation |= + LOCK_NB; + } + + if ( + flock( + descriptor, + operation + ) != 0 + ) { + (void)close( + descriptor + ); + + return -1; + } + + return descriptor; +} + +static void sync_lock_close( + int descriptor +) +{ + if (descriptor < 0) { + return; + } + + (void)flock( + descriptor, + LOCK_UN + ); + + (void)close( + descriptor + ); +} + +static bool sync_silence_begin( + SyncSilence *silence +) +{ + if (silence == NULL) { + return false; + } + + silence->saved_stdout = -1; + silence->saved_stderr = -1; + silence->null_fd = -1; + + (void)fflush(stdout); + (void)fflush(stderr); + + silence->saved_stdout = + dup( + STDOUT_FILENO + ); + + silence->saved_stderr = + dup( + STDERR_FILENO + ); + + silence->null_fd = + open( + "/dev/null", + O_WRONLY | + O_CLOEXEC + ); + + if ( + silence->saved_stdout < 0 || + silence->saved_stderr < 0 || + silence->null_fd < 0 + ) { + return false; + } + + if ( + dup2( + silence->null_fd, + STDOUT_FILENO + ) < 0 || + dup2( + silence->null_fd, + STDERR_FILENO + ) < 0 + ) { + return false; + } + + return true; +} + +static void sync_silence_end( + SyncSilence *silence +) +{ + if (silence == NULL) { + return; + } + + (void)fflush(stdout); + (void)fflush(stderr); + + if ( + silence->saved_stdout >= 0 + ) { + (void)dup2( + silence->saved_stdout, + STDOUT_FILENO + ); + + (void)close( + silence->saved_stdout + ); + } + + if ( + silence->saved_stderr >= 0 + ) { + (void)dup2( + silence->saved_stderr, + STDERR_FILENO + ); + + (void)close( + silence->saved_stderr + ); + } + + if ( + silence->null_fd >= 0 + ) { + (void)close( + silence->null_fd + ); + } + + silence->saved_stdout = -1; + silence->saved_stderr = -1; + silence->null_fd = -1; +} + +static TrainlogStatus sync_probe_unlocked( + TrainlogSyncDeviceInfo *output +) +{ + TrainlogUsbDevice + devices[SYNC_DEVICE_CAPACITY]; + + TrainlogMtpStorage + storages[SYNC_STORAGE_CAPACITY]; + + size_t device_count = 0U; + size_t storage_count = 0U; + TrainlogStatus status; + + if (output == NULL) { + return + TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + (void)memset( + output, + 0, + sizeof(*output) + ); + + status = + trainlog_usb_list_mtp_devices( + devices, + SYNC_DEVICE_CAPACITY, + &device_count + ); + + if ( + status != + TRAINLOG_STATUS_OK || + device_count == 0U + ) { + return + TRAINLOG_STATUS_NOT_FOUND; + } + + output->connected = true; + output->device = devices[0]; + + status = + trainlog_mtp_list_storages( + output->device.bus_number, + output->device.device_number, + storages, + SYNC_STORAGE_CAPACITY, + &storage_count + ); + + if ( + status != + TRAINLOG_STATUS_OK || + storage_count == 0U + ) { + return + TRAINLOG_STATUS_NOT_FOUND; + } + + output->storage_ready = true; + output->storage = storages[0]; + + return TRAINLOG_STATUS_OK; +} + +TrainlogStatus trainlog_sync_probe( + TrainlogSyncDeviceInfo *output +) +{ + SyncSilence silence; + int lock_fd; + TrainlogStatus status; + + if (output == NULL) { + return + TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + lock_fd = + sync_lock_open( + false + ); + + if (lock_fd < 0) { + return TRAINLOG_STATUS_CONFLICT; + } + + if ( + !sync_silence_begin( + &silence + ) + ) { + sync_silence_end( + &silence + ); + + sync_lock_close( + lock_fd + ); + + return + TRAINLOG_STATUS_SYSTEM_ERROR; + } + + status = + sync_probe_unlocked( + output + ); + + sync_silence_end( + &silence + ); + + sync_lock_close( + lock_fd + ); + + return status; +} + +static TrainlogStatus sync_find_child( + const TrainlogSyncDeviceInfo *device, + 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 || + !device->connected || + !device->storage_ready + ) { + return + TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + *output_id = 0U; + *output_size = 0U; + + status = + trainlog_mtp_list_folder( + device->device.bus_number, + device->device.device_number, + device->storage.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_exchange_folder( + const TrainlogSyncDeviceInfo *device, + uint32_t *output_folder_id +) +{ + uint32_t download_id = 0U; + uint64_t ignored_size = 0U; + TrainlogStatus status; + + if ( + output_folder_id == NULL + ) { + return + TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + status = + sync_find_child( + device, + UINT32_MAX, + "Download", + true, + &download_id, + &ignored_size + ); + + if ( + status != + TRAINLOG_STATUS_OK + ) { + return status; + } + + return + sync_find_child( + device, + download_id, + "Trainlog", + true, + output_folder_id, + &ignored_size + ); +} + +static TrainlogStatus sync_receive_named( + const TrainlogSyncDeviceInfo *device, + uint32_t folder_id, + const char *name, + const char *local_path, + uint64_t *output_size +) +{ + uint32_t item_id = 0U; + uint64_t size_bytes = 0U; + TrainlogStatus status; + + if ( + name == NULL || + local_path == NULL + ) { + return + TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + status = + sync_find_child( + device, + folder_id, + name, + false, + &item_id, + &size_bytes + ); + + if ( + status != + TRAINLOG_STATUS_OK + ) { + return status; + } + + status = + trainlog_mtp_receive_file( + device->device.bus_number, + device->device.device_number, + item_id, + local_path + ); + + if ( + status == + TRAINLOG_STATUS_OK && + output_size != NULL + ) { + *output_size = + size_bytes; + } + + return status; +} + +static TrainlogStatus sync_publish_named( + const TrainlogSyncDeviceInfo *device, + uint32_t folder_id, + const char *local_path, + const char *remote_name +) +{ + uint32_t existing_id = 0U; + uint32_t uploaded_id = 0U; + uint64_t ignored_size = 0U; + TrainlogStatus status; + + status = + sync_find_child( + device, + folder_id, + remote_name, + false, + &existing_id, + &ignored_size + ); + + if ( + status == + TRAINLOG_STATUS_OK + ) { + status = + trainlog_mtp_delete_object( + device->device.bus_number, + device->device.device_number, + existing_id + ); + + if ( + status != + TRAINLOG_STATUS_OK + ) { + return status; + } + } else if ( + status != + TRAINLOG_STATUS_NOT_FOUND + ) { + return status; + } + + return + trainlog_mtp_send_text_file( + device->device.bus_number, + device->device.device_number, + device->storage.storage_id, + folder_id, + local_path, + remote_name, + &uploaded_id + ); +} + +static bool sync_resolve_repo_tool( + const char *tool_name, + char *output, + size_t output_size +) +{ + char executable[ + PATH_MAX + 1U + ]; + + ssize_t length; + char *slash; + int level; + 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 bool sync_read_text( + const char *path, + char *output, + size_t output_size +) +{ + FILE *file; + size_t used; + + if ( + path == NULL || + output == NULL || + output_size < 2U + ) { + return false; + } + + file = + fopen( + path, + "rb" + ); + + if (file == NULL) { + return false; + } + + used = + fread( + output, + 1U, + output_size - 1U, + file + ); + + if ( + ferror(file) != 0 + ) { + (void)fclose(file); + return false; + } + + output[used] = '\0'; + + return + fclose(file) == 0; +} + +static TrainlogStatus sync_run_python_tool( + const char *tool_name, + const char *argument, + const char *result_path, + char *output, + size_t output_size +) +{ + char tool[ + PATH_MAX + 1U + ]; + + pid_t child; + int child_status; + int result_fd; + + if ( + tool_name == NULL || + argument == NULL || + result_path == NULL || + output == NULL || + output_size < 2U + ) { + return + TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + output[0] = '\0'; + + if ( + !sync_resolve_repo_tool( + tool_name, + tool, + sizeof(tool) + ) + ) { + return + TRAINLOG_STATUS_NOT_FOUND; + } + + result_fd = + open( + result_path, + O_WRONLY | + O_CREAT | + O_TRUNC | + O_CLOEXEC, + 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, + argument, + (char *)NULL + ); + + _exit(127); + } + + (void)close( + result_fd + ); + + if ( + waitpid( + child, + &child_status, + 0 + ) < (pid_t)0 + ) { + return + TRAINLOG_STATUS_SYSTEM_ERROR; + } + + (void)sync_read_text( + result_path, + output, + output_size + ); + + if ( + !WIFEXITED( + child_status + ) || + WEXITSTATUS( + child_status + ) != 0 + ) { + return + TRAINLOG_STATUS_DATABASE_ERROR; + } + + return + TRAINLOG_STATUS_OK; +} + +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 void sync_last_nonempty_line( + const char *text, + char *output, + size_t output_size +) +{ + const char *cursor; + const char *line_start; + size_t best_length = 0U; + const char *best = NULL; + + if ( + output == NULL || + output_size == 0U + ) { + return; + } + + output[0] = '\0'; + + if (text == NULL) { + return; + } + + cursor = text; + line_start = text; + + for (;;) { + if ( + *cursor == '\n' || + *cursor == '\0' + ) { + size_t length = + (size_t)( + cursor - + line_start + ); + + while ( + length > 0U && + ( + line_start[length - 1U] == + '\r' || + line_start[length - 1U] == + ' ' || + line_start[length - 1U] == + '\t' + ) + ) { + --length; + } + + if (length > 0U) { + best = line_start; + best_length = length; + } + + if (*cursor == '\0') { + break; + } + + line_start = + cursor + 1; + } + + ++cursor; + } + + if (best != NULL) { + size_t copy_length = + best_length < + output_size - 1U + ? best_length + : output_size - 1U; + + (void)memcpy( + output, + best, + copy_length + ); + + output[copy_length] = '\0'; + } +} + +static bool sync_json_string( + const char *text, + const char *key, + char *output, + size_t output_size +) +{ + char pattern[128]; + const char *position; + const char *quote; + const char *end; + int written; + size_t length; + + if ( + text == NULL || + key == NULL || + output == NULL || + output_size < 2U + ) { + return false; + } + + written = + snprintf( + pattern, + sizeof(pattern), + "\"%s\"", + key + ); + + if ( + written < 0 || + (size_t)written >= + sizeof(pattern) + ) { + return false; + } + + position = + strstr( + text, + pattern + ); + + if (position == NULL) { + return false; + } + + position += + strlen(pattern); + + position = + strchr( + position, + ':' + ); + + if (position == NULL) { + return false; + } + + ++position; + + while ( + *position == ' ' || + *position == '\t' || + *position == '\r' || + *position == '\n' + ) { + ++position; + } + + if (*position != '"') { + return false; + } + + quote = position + 1; + + end = + strchr( + quote, + '"' + ); + + if (end == NULL) { + return false; + } + + length = + (size_t)( + end - + quote + ); + + if ( + length == 0U || + length >= output_size + ) { + return false; + } + + (void)memcpy( + output, + quote, + length + ); + + output[length] = '\0'; + + return true; +} + +static bool sync_parse_request( + const char *text, + char *output_request_id, + size_t output_size +) +{ + char format[64]; + + if ( + text == NULL || + output_request_id == NULL || + output_size < 2U || + !sync_json_string( + text, + "format", + format, + sizeof(format) + ) || + strcmp( + format, + "trainlog-sync-request" + ) != 0 || + strstr( + text, + "\"version\":1" + ) == NULL + ) { + return false; + } + + return + sync_json_string( + text, + "request_id", + output_request_id, + output_size + ); +} + +static bool sync_last_request_matches( + const char *request_id +) +{ + char path[ + PATH_MAX + 1U + ]; + + char saved[ + TRAINLOG_ID_MAX + 2U + ]; + + size_t length; + + if ( + request_id == NULL || + request_id[0] == '\0' || + !sync_data_file( + "sync_last_request.txt", + path, + sizeof(path) + ) || + !sync_read_text( + path, + saved, + sizeof(saved) + ) + ) { + return false; + } + + length = + strlen(saved); + + while ( + length > 0U && + ( + saved[length - 1U] == + '\n' || + saved[length - 1U] == + '\r' + ) + ) { + --length; + } + + saved[length] = '\0'; + + return + strcmp( + saved, + request_id + ) == 0; +} + +static bool sync_save_last_request( + const char *request_id +) +{ + char path[ + PATH_MAX + 1U + ]; + + FILE *file; + + if ( + request_id == NULL || + request_id[0] == '\0' || + !sync_data_file( + "sync_last_request.txt", + path, + sizeof(path) + ) + ) { + return false; + } + + file = + fopen( + path, + "wb" + ); + + if (file == NULL) { + return false; + } + + if ( + fprintf( + file, + "%s\n", + request_id + ) < 0 + ) { + (void)fclose(file); + return false; + } + + return + fclose(file) == 0; +} + +static const char *sync_trigger_text( + TrainlogSyncTrigger trigger +) +{ + switch (trigger) { + case TRAINLOG_SYNC_TRIGGER_ANDROID: + return "android"; + + case TRAINLOG_SYNC_TRIGGER_DAEMON: + return "daemon"; + + case TRAINLOG_SYNC_TRIGGER_TUI: + default: + return "tui"; + } +} + +static bool sync_local_timestamp( + char output[17] +) +{ + time_t now = + time(NULL); + + struct tm local_time; + + if ( + now == (time_t)-1 || + localtime_r( + &now, + &local_time + ) == NULL + ) { + return false; + } + + return + strftime( + output, + 17U, + "%d/%m/%Y %H:%M", + &local_time + ) != 0U; +} + +static bool sync_json_write_escaped( + FILE *file, + const char *text +) +{ + const unsigned char *cursor; + + if ( + file == NULL || + text == NULL + ) { + return false; + } + + if ( + fputc( + '"', + file + ) == EOF + ) { + return false; + } + + cursor = + (const unsigned char *)text; + + while (*cursor != 0U) { + switch (*cursor) { + case '"': + if ( + fputs( + "\\\"", + file + ) == EOF + ) { + return false; + } + break; + + case '\\': + if ( + fputs( + "\\\\", + file + ) == EOF + ) { + return false; + } + break; + + case '\n': + if ( + fputs( + "\\n", + file + ) == EOF + ) { + return false; + } + break; + + case '\r': + if ( + fputs( + "\\r", + file + ) == EOF + ) { + return false; + } + break; + + case '\t': + if ( + fputs( + "\\t", + file + ) == EOF + ) { + return false; + } + break; + + default: + if ( + fputc( + (int)*cursor, + file + ) == EOF + ) { + return false; + } + break; + } + + ++cursor; + } + + return + fputc( + '"', + file + ) != EOF; +} + +static bool sync_write_receipt( + const TrainlogSyncReport *report +) +{ + FILE *file; + + if ( + report == NULL || + report->request_id[0] == '\0' + ) { + return false; + } + + file = + fopen( + SYNC_RECEIPT_LOCAL, + "wb" + ); + + if (file == NULL) { + return false; + } + + (void)fputs( + "{\n \"format\":\"trainlog-sync-receipt\",\n" + " \"version\":1,\n" + " \"request_id\":", + file + ); + + if ( + !sync_json_write_escaped( + file, + report->request_id + ) + ) { + (void)fclose(file); + return false; + } + + (void)fputs( + ",\n \"sync_id\":", + file + ); + + if ( + !sync_json_write_escaped( + file, + report->sync_id + ) + ) { + (void)fclose(file); + return false; + } + + (void)fputs( + ",\n \"status\":", + file + ); + + if ( + !sync_json_write_escaped( + file, + report->success + ? "success" + : "failure" + ) + ) { + (void)fclose(file); + return false; + } + + (void)fputs( + ",\n \"summary\":", + file + ); + + if ( + !sync_json_write_escaped( + file, + report->summary + ) + ) { + (void)fclose(file); + return false; + } + + (void)fprintf( + file, + ",\n \"android_to_pc\":{" + "\"exercises_imported\":%zu," + "\"exercises_reconciled\":%zu," + "\"exercises_skipped\":%zu," + "\"sessions_imported\":%zu," + "\"sessions_skipped\":%zu," + "\"body_imported\":%zu," + "\"body_skipped\":%zu" + "},\n" + " \"pc_to_android\":{" + "\"catalog_published\":%zu" + "}\n}\n", + report->exercises_imported, + report->exercises_reconciled, + report->exercises_skipped, + report->sessions_imported, + report->sessions_skipped, + report->body_imported, + report->body_skipped, + report->catalog_published + ); + + return + fclose(file) == 0; +} + +static bool sync_record_run( + TrainlogSyncTrigger trigger, + const TrainlogSyncReport *report +) +{ + char directory[ + PATH_MAX + 1U + ]; + + char json_path[ + PATH_MAX + 1U + ]; + + char detail_path[ + PATH_MAX + 1U + ]; + + char history_path[ + PATH_MAX + 1U + ]; + + char local_timestamp[17]; + + FILE *json_file; + FILE *detail_file; + FILE *history_file; + int written; + + if ( + report == NULL || + report->sync_id[0] == '\0' || + !sync_runs_directory( + directory, + sizeof(directory) + ) || + !sync_data_file( + "sync_history.log", + history_path, + sizeof(history_path) + ) || + !sync_local_timestamp( + local_timestamp + ) + ) { + return false; + } + + written = + snprintf( + json_path, + sizeof(json_path), + "%s/%s.json", + directory, + report->sync_id + ); + + if ( + written < 0 || + (size_t)written >= + sizeof(json_path) + ) { + return false; + } + + written = + snprintf( + detail_path, + sizeof(detail_path), + "%s/%s.txt", + directory, + report->sync_id + ); + + if ( + written < 0 || + (size_t)written >= + sizeof(detail_path) + ) { + return false; + } + + json_file = + fopen( + json_path, + "wb" + ); + + if (json_file == NULL) { + return false; + } + + (void)fputs( + "{\n" + " \"format\":\"trainlog-sync-run\",\n" + " \"version\":1,\n" + " \"sync_id\":", + json_file + ); + + if ( + !sync_json_write_escaped( + json_file, + report->sync_id + ) + ) { + (void)fclose(json_file); + return false; + } + + (void)fputs( + ",\n \"started_at\":", + json_file + ); + + if ( + !sync_json_write_escaped( + json_file, + report->started_at + ) + ) { + (void)fclose(json_file); + return false; + } + + (void)fputs( + ",\n \"trigger\":", + json_file + ); + + if ( + !sync_json_write_escaped( + json_file, + sync_trigger_text( + trigger + ) + ) + ) { + (void)fclose(json_file); + return false; + } + + (void)fputs( + ",\n \"status\":", + json_file + ); + + if ( + !sync_json_write_escaped( + json_file, + report->success + ? "success" + : "failure" + ) + ) { + (void)fclose(json_file); + return false; + } + + (void)fputs( + ",\n \"request_id\":", + json_file + ); + + if ( + !sync_json_write_escaped( + json_file, + report->request_id + ) + ) { + (void)fclose(json_file); + return false; + } + + (void)fputs( + ",\n \"summary\":", + json_file + ); + + if ( + !sync_json_write_escaped( + json_file, + report->summary + ) + ) { + (void)fclose(json_file); + return false; + } + + (void)fprintf( + json_file, + ",\n \"android_to_pc\":{" + "\"exercises_imported\":%zu," + "\"exercises_reconciled\":%zu," + "\"exercises_skipped\":%zu," + "\"sessions_imported\":%zu," + "\"sessions_skipped\":%zu," + "\"body_imported\":%zu," + "\"body_skipped\":%zu" + "},\n" + " \"pc_to_android\":{" + "\"catalog_published\":%zu" + "}\n}\n", + report->exercises_imported, + report->exercises_reconciled, + report->exercises_skipped, + report->sessions_imported, + report->sessions_skipped, + report->body_imported, + report->body_skipped, + report->catalog_published + ); + + if ( + fclose( + json_file + ) != 0 + ) { + return false; + } + + detail_file = + fopen( + detail_path, + "wb" + ); + + if (detail_file == NULL) { + return false; + } + + (void)fprintf( + detail_file, + "SYNC %s\n\n" + "Déclencheur : %s\n" + "Début : %s\n" + "État : %s\n", + report->sync_id, + sync_trigger_text( + trigger + ), + local_timestamp, + report->success + ? "succès" + : "échec" + ); + + if ( + report->request_id[0] != '\0' + ) { + (void)fprintf( + detail_file, + "Requête : %s\n", + report->request_id + ); + } + + (void)fprintf( + detail_file, + "\nANDROID → PC\n" + " Exercices importés +%zu\n" + " Exercices réconciliés +%zu\n" + " Exercices déjà présents %zu\n" + " Séances importées +%zu\n" + " Séances déjà présentes %zu\n" + " Mensurations importées +%zu\n" + " Mensurations déjà présentes %zu\n" + "\nPC → ANDROID\n" + " Catalogue publié %zu exercice(s)\n" + "\nRésumé\n" + " %s\n", + report->exercises_imported, + report->exercises_reconciled, + report->exercises_skipped, + report->sessions_imported, + report->sessions_skipped, + report->body_imported, + report->body_skipped, + report->catalog_published, + report->summary + ); + + if ( + report->error[0] != '\0' + ) { + (void)fprintf( + detail_file, + "\nErreur\n %s\n", + report->error + ); + } + + if ( + fclose( + detail_file + ) != 0 + ) { + return false; + } + + history_file = + fopen( + history_path, + "ab" + ); + + if (history_file == NULL) { + return false; + } + + (void)fprintf( + history_file, + "%s\t%s\t%d\t%.*s\n", + report->sync_id, + local_timestamp, + report->success + ? 1 + : 0, + (int)TRAINLOG_SYNC_SUMMARY_MAX, + report->summary + ); + + return + fclose( + history_file + ) == 0; +} + +static void sync_build_summary( + TrainlogSyncReport *report +) +{ + if (report == NULL) { + return; + } + + if (report->success) { + (void)snprintf( + report->summary, + sizeof(report->summary), + "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 + ); + } else { + (void)snprintf( + report->summary, + sizeof(report->summary), + "%s", + report->error[0] != '\0' + ? report->error + : "Synchronisation échouée." + ); + } +} + +static TrainlogStatus sync_prepare_run_identity( + TrainlogSyncReport *report +) +{ + if ( + report == NULL || + trainlog_id_generate( + "sy", + report->sync_id, + sizeof(report->sync_id) + ) != TRAINLOG_STATUS_OK || + trainlog_time_now_rfc3339( + report->started_at, + sizeof(report->started_at) + ) != TRAINLOG_STATUS_OK + ) { + return + TRAINLOG_STATUS_SYSTEM_ERROR; + } + + return TRAINLOG_STATUS_OK; +} + +static void sync_parse_import_report( + const char *text, + TrainlogSyncReport *report +) +{ + if ( + text == NULL || + report == NULL + ) { + return; + } + + report->exercises_imported = + sync_report_value( + text, + "exercises_imported" + ); + + report->exercises_reconciled = + sync_report_value( + text, + "exercises_reconciled" + ); + + report->exercises_skipped = + sync_report_value( + text, + "exercises_skipped" + ); + + report->sessions_imported = + sync_report_value( + text, + "sessions_imported" + ); + + report->sessions_skipped = + sync_report_value( + text, + "sessions_skipped" + ); + + report->body_imported = + sync_report_value( + text, + "body_imported" + ); + + report->body_skipped = + sync_report_value( + text, + "body_skipped" + ); +} + +/* TRAINLOG_SYNCD_NO_UNKNOWN_ERRORS */ +TrainlogStatus trainlog_sync_run( + TrainlogSyncTrigger trigger, + bool require_request, + TrainlogSyncReport *output +) +{ + TrainlogSyncDeviceInfo device; + SyncSilence silence; + TrainlogStatus status; + TrainlogStatus final_status = + TRAINLOG_STATUS_OK; + + char tool_output[ + SYNC_TOOL_OUTPUT_MAX + 1U + ]; + + char request_text[ + SYNC_REQUEST_TEXT_MAX + 1U + ]; + + uint32_t folder_id = 0U; + uint64_t ignored_size = 0U; + int lock_fd = -1; + bool silence_active = false; + bool run_started = false; + bool receipt_published = false; + + if (output == NULL) { + return + TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + (void)memset( + output, + 0, + sizeof(*output) + ); + + lock_fd = + sync_lock_open( + !require_request + ); + + if (lock_fd < 0) { + (void)snprintf( + output->error, + sizeof(output->error), + "%s", + "Une autre synchronisation est déjà en cours." + ); + + return TRAINLOG_STATUS_CONFLICT; + } + + if ( + !sync_silence_begin( + &silence + ) + ) { + sync_silence_end( + &silence + ); + + sync_lock_close( + lock_fd + ); + + (void)snprintf( + output->error, + sizeof(output->error), + "%s", + "Impossible d'isoler la sortie MTP." + ); + + return + TRAINLOG_STATUS_SYSTEM_ERROR; + } + + silence_active = true; + + status = + sync_probe_unlocked( + &device + ); + + if ( + status != + TRAINLOG_STATUS_OK + ) { + (void)snprintf( + output->error, + sizeof(output->error), + "Détection MTP échouée (status=%d).", + (int)status + ); + + final_status = status; + goto done; + } + + status = + sync_find_exchange_folder( + &device, + &folder_id + ); + + if ( + status != + TRAINLOG_STATUS_OK + ) { + (void)snprintf( + output->error, + sizeof(output->error), + "Résolution de Download/Trainlog échouée (status=%d).", + (int)status + ); + + final_status = status; + goto done; + } + + if (require_request) { + status = + sync_receive_named( + &device, + folder_id, + SYNC_REQUEST_NAME, + SYNC_REQUEST_LOCAL, + &ignored_size + ); + + if ( + status == + TRAINLOG_STATUS_NOT_FOUND + ) { + final_status = + TRAINLOG_STATUS_NOT_FOUND; + goto done; + } + + if ( + status != + TRAINLOG_STATUS_OK + ) { + (void)snprintf( + output->error, + sizeof(output->error), + "Lecture MTP de la requête Android échouée (status=%d).", + (int)status + ); + + final_status = status; + goto done; + } + + if ( + !sync_read_text( + SYNC_REQUEST_LOCAL, + request_text, + sizeof(request_text) + ) + ) { + (void)snprintf( + output->error, + sizeof(output->error), + "%s", + "Lecture locale de la requête Android échouée." + ); + + final_status = + TRAINLOG_STATUS_SYSTEM_ERROR; + goto done; + } + + if ( + !sync_parse_request( + request_text, + output->request_id, + sizeof(output->request_id) + ) + ) { + (void)snprintf( + output->error, + sizeof(output->error), + "%s", + "JSON de requête Android invalide." + ); + + final_status = + TRAINLOG_STATUS_SYSTEM_ERROR; + goto done; + } + + output->request_present = true; + + if ( + sync_last_request_matches( + output->request_id + ) + ) { + final_status = + TRAINLOG_STATUS_NOT_FOUND; + goto done; + } + } + + status = + sync_prepare_run_identity( + output + ); + + if ( + status != + TRAINLOG_STATUS_OK + ) { + (void)snprintf( + output->error, + sizeof(output->error), + "Création de l'identité de synchronisation échouée (status=%d).", + (int)status + ); + + final_status = status; + goto done; + } + + run_started = true; + + status = + sync_receive_named( + &device, + folder_id, + MOBILE_EXPORT_NAME, + MOBILE_EXPORT_LOCAL, + &ignored_size + ); + + if ( + status != + TRAINLOG_STATUS_OK + ) { + (void)snprintf( + output->error, + sizeof(output->error), + "%s", + "Android→PC : snapshot mobile introuvable ou illisible." + ); + + final_status = status; + goto finalize; + } + + status = + sync_run_python_tool( + "import_mobile_export.py", + MOBILE_EXPORT_LOCAL, + MOBILE_IMPORT_RESULT, + tool_output, + sizeof(tool_output) + ); + + if ( + status != + TRAINLOG_STATUS_OK || + strstr( + tool_output, + "MOBILE_IMPORT=PASS" + ) == NULL + ) { + char useful[ + TRAINLOG_SYNC_ERROR_MAX + 1U + ]; + + sync_last_nonempty_line( + tool_output, + useful, + sizeof(useful) + ); + + (void)snprintf( + output->error, + sizeof(output->error), + "Android→PC : %s", + useful[0] != '\0' + ? useful + : "import mobile échoué" + ); + + final_status = + TRAINLOG_STATUS_DATABASE_ERROR; + goto finalize; + } + + sync_parse_import_report( + tool_output, + output + ); + + status = + sync_run_python_tool( + "export_pc_catalog.py", + PC_CATALOG_LOCAL, + PC_CATALOG_RESULT, + tool_output, + sizeof(tool_output) + ); + + if ( + status != + TRAINLOG_STATUS_OK || + strstr( + tool_output, + "PC_CATALOG_EXPORT=PASS" + ) == NULL + ) { + char useful[ + TRAINLOG_SYNC_ERROR_MAX + 1U + ]; + + sync_last_nonempty_line( + tool_output, + useful, + sizeof(useful) + ); + + (void)snprintf( + output->error, + sizeof(output->error), + "PC→Android : %s", + useful[0] != '\0' + ? useful + : "export catalogue échoué" + ); + + final_status = + TRAINLOG_STATUS_SYSTEM_ERROR; + goto finalize; + } + + output->catalog_published = + sync_report_value( + tool_output, + "exercises" + ); + + status = + sync_publish_named( + &device, + folder_id, + PC_CATALOG_LOCAL, + PC_CATALOG_NAME + ); + + if ( + status != + TRAINLOG_STATUS_OK + ) { + (void)snprintf( + output->error, + sizeof(output->error), + "%s", + "PC→Android : publication MTP du catalogue échouée." + ); + + final_status = status; + goto finalize; + } + + output->success = true; + final_status = + TRAINLOG_STATUS_OK; + +finalize: + sync_build_summary( + output + ); + + if ( + output->request_present + ) { + if ( + sync_write_receipt( + output + ) + ) { + status = + sync_publish_named( + &device, + folder_id, + SYNC_RECEIPT_LOCAL, + SYNC_RECEIPT_NAME + ); + + if ( + status == + TRAINLOG_STATUS_OK + ) { + receipt_published = true; + } + } + + if (!receipt_published) { + output->success = false; + + (void)snprintf( + output->error, + sizeof(output->error), + "%s", + "PC→Android : impossible de publier le reçu de synchronisation." + ); + + final_status = + TRAINLOG_STATUS_SYSTEM_ERROR; + + sync_build_summary( + output + ); + } + } + + (void)sync_record_run( + trigger, + output + ); + + if ( + output->request_present && + receipt_published + ) { + (void)sync_save_last_request( + output->request_id + ); + } + +done: + if (silence_active) { + sync_silence_end( + &silence + ); + } + + sync_lock_close( + lock_fd + ); + + if ( + run_started && + !output->success && + output->error[0] == '\0' + ) { + (void)snprintf( + output->error, + sizeof(output->error), + "%s", + "Synchronisation échouée." + ); + } + + return final_status; +} diff --git a/tui/src/tui.c b/tui/src/tui.c index 1812bcc..a9c2f55 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -25,6 +25,7 @@ #include "trainlog/duration.h" #include "trainlog/id.h" #include "trainlog/mtp.h" +#include "trainlog/sync.h" #include "trainlog/reps.h" #include "trainlog/theme.h" #include "trainlog/timeutil.h" @@ -8600,803 +8601,21 @@ static void screen_body( } /* TRAINLOG_SYNC_TUI */ +/* TRAINLOG_SHARED_SYNC_ENGINE_V1 */ -#define SYNC_DEVICE_CAPACITY 8U -#define SYNC_STORAGE_CAPACITY 8U -#define SYNC_ENTRY_CAPACITY 128U - -typedef struct TrainlogSyncOverview { - bool connected; - bool storage_ready; - bool exchange_ready; - size_t device_count; - size_t remote_entry_count; - size_t remote_json_count; - size_t local_exercise_count; - TrainlogUsbDevice device; - TrainlogMtpStorage storage; - uint32_t exchange_folder_id; -} TrainlogSyncOverview; - -static bool sync_name_has_json_suffix( - const char *name -) -{ - size_t length; - - if (name == NULL) { - return false; - } - - length = strlen(name); - - return - length >= 5U && - strcmp( - name + length - 5U, - ".json" - ) == 0; -} - -static double sync_bytes_to_gib( - uint64_t bytes -) -{ - return - (double)bytes / - (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 -) -{ - TrainlogUsbDevice devices[SYNC_DEVICE_CAPACITY]; - TrainlogMtpStorage storages[SYNC_STORAGE_CAPACITY]; - TrainlogMtpEntry entries[SYNC_ENTRY_CAPACITY]; - - size_t device_count = 0U; - size_t storage_count = 0U; - size_t entry_count = 0U; - size_t index; - bool folder_created = false; - - if (overview == NULL) { - return; - } - - (void)memset( - overview, - 0, - sizeof(*overview) - ); - - (void)trainlog_database_exercise_count( - database, - &overview->local_exercise_count - ); - - if (trainlog_usb_list_mtp_devices( - devices, - SYNC_DEVICE_CAPACITY, - &device_count - ) != TRAINLOG_STATUS_OK || - device_count == 0U) { - return; - } - - overview->connected = true; - overview->device_count = device_count; - overview->device = devices[0]; - - if (trainlog_mtp_list_storages( - overview->device.bus_number, - overview->device.device_number, - storages, - SYNC_STORAGE_CAPACITY, - &storage_count - ) != TRAINLOG_STATUS_OK || - storage_count == 0U) { - return; - } - - overview->storage_ready = true; - overview->storage = storages[0]; - - if (trainlog_mtp_ensure_root_folder( - overview->device.bus_number, - overview->device.device_number, - overview->storage.storage_id, - "Trainlog", - &overview->exchange_folder_id, - &folder_created - ) != TRAINLOG_STATUS_OK) { - return; - } - - (void)folder_created; - overview->exchange_ready = true; - - if (trainlog_mtp_list_folder( - overview->device.bus_number, - overview->device.device_number, - overview->storage.storage_id, - overview->exchange_folder_id, - entries, - SYNC_ENTRY_CAPACITY, - &entry_count - ) != TRAINLOG_STATUS_OK) { - return; - } - - overview->remote_entry_count = - entry_count; - - for (index = 0U; - index < entry_count; - ++index) { - if (!entries[index].folder && - sync_name_has_json_suffix( - entries[index].name - )) { - ++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 +#define SYNC_HISTORY_CAPACITY 64U +#define SYNC_HISTORY_TEXT_MAX TRAINLOG_SYNC_SUMMARY_MAX +#define SYNC_DETAIL_LINE_CAPACITY 160U +#define SYNC_DETAIL_TEXT_CAPACITY 16384U typedef struct TrainlogSyncHistoryEntry { + char sync_id[ + TRAINLOG_ID_MAX + 1U + ]; + char timestamp[17]; bool success; + char summary[ SYNC_HISTORY_TEXT_MAX + 1U ]; @@ -9446,10 +8665,14 @@ static bool sync_history_path( ) { const char *data_home = - getenv("XDG_DATA_HOME"); + getenv( + "XDG_DATA_HOME" + ); const char *home = - getenv("HOME"); + getenv( + "HOME" + ); int written; @@ -9492,75 +8715,65 @@ static bool sync_history_path( output_size; } -static void sync_history_append( - bool success, - const char *summary +static bool sync_runs_path( + const char *sync_id, + char *output, + size_t output_size ) { - 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" + const char *data_home = + getenv( + "XDG_DATA_HOME" ); - if (file == NULL) { - return; + const char *home = + getenv( + "HOME" + ); + + int written; + + if ( + sync_id == NULL || + sync_id[0] == '\0' || + output == NULL || + output_size == 0U + ) { + return false; } - (void)fprintf( - file, - "%s\t%d\t%.*s\n", - timestamp, - success - ? 1 - : 0, - (int)SYNC_HISTORY_TEXT_MAX, - summary - ); + if ( + data_home != NULL && + data_home[0] != '\0' + ) { + written = + snprintf( + output, + output_size, + "%s/trainlog/sync_runs/%s.txt", + data_home, + sync_id + ); + } else if ( + home != NULL && + home[0] != '\0' + ) { + written = + snprintf( + output, + output_size, + "%s/.local/share/trainlog/sync_runs/%s.txt", + home, + sync_id + ); + } else { + return false; + } - (void)fclose(file); + return + written >= 0 && + (size_t)written < + output_size; } static void sync_history_load( @@ -9580,7 +8793,7 @@ static void sync_history_load( size_t next = 0U; size_t copied; FILE *file; - char line[512]; + char line[768]; if ( output_count == NULL || @@ -9626,38 +8839,21 @@ static void sync_history_load( file ) != NULL ) { - char *first_tab; - char *second_tab; + char *first; + char *second; + char *third; char *newline; + + const char *sync_id; + const char *timestamp; + const char *status_text; + const char *summary; + 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, + line, '\n' ); @@ -9665,19 +8861,70 @@ static void sync_history_load( *newline = '\0'; } + first = + strchr( + line, + '\t' + ); + + if (first == NULL) { + continue; + } + + *first = '\0'; + + second = + strchr( + first + 1, + '\t' + ); + + if (second == NULL) { + continue; + } + + *second = '\0'; + + third = + strchr( + second + 1, + '\t' + ); + + if (third != NULL) { + *third = '\0'; + + sync_id = line; + timestamp = first + 1; + status_text = second + 1; + summary = third + 1; + } else { + sync_id = ""; + timestamp = line; + status_text = first + 1; + summary = second + 1; + } + entry = &ring[next]; + (void)snprintf( + entry->sync_id, + sizeof(entry->sync_id), + "%s", + sync_id + ); + (void)snprintf( entry->timestamp, sizeof(entry->timestamp), "%s", - line + timestamp ); entry->success = strcmp( - first_tab + 1, + status_text, "1" ) == 0; @@ -9685,7 +8932,7 @@ static void sync_history_load( entry->summary, sizeof(entry->summary), "%s", - second_tab + 1 + summary ); next = @@ -9702,7 +8949,9 @@ static void sync_history_load( } } - (void)fclose(file); + (void)fclose( + file + ); copied = count < capacity @@ -9731,583 +8980,266 @@ static void sync_history_load( copied; } -static void sync_load_overview_silenced( - TrainlogDatabase *database, - TrainlogSyncOverview *overview +static void screen_sync_run_detail( + const char *sync_id ) { - 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[ + char path[ 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 text[ + SYNC_DETAIL_TEXT_CAPACITY ]; - char result[512]; - int result_fd; - pid_t child; - int child_status; + char *lines[ + SYNC_DETAIL_LINE_CAPACITY + ]; + + size_t line_count = 0U; + size_t offset = 0U; 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) + !sync_runs_path( + sync_id, + path, + sizeof(path) ) ) { - 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; + return; } file = fopen( - RESULT_PATH, + path, "rb" ); if (file == NULL) { - return TRAINLOG_STATUS_SYSTEM_ERROR; + return; } used = fread( - result, + text, 1U, - sizeof(result) - 1U, + sizeof(text) - 1U, file ); - result[used] = '\0'; + text[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 + (void)fclose( + file ); - return status; + if (used > 0U) { + char *cursor = text; + + lines[line_count] = + cursor; + + ++line_count; + + while ( + *cursor != '\0' && + line_count < + SYNC_DETAIL_LINE_CAPACITY + ) { + if (*cursor == '\n') { + *cursor = '\0'; + + if ( + cursor[1] != '\0' + ) { + lines[line_count] = + cursor + 1; + + ++line_count; + } + } + + ++cursor; + } + } + + for (;;) { + bool large_layout = + COLS >= 100 && + LINES >= 30; + + int panel_top = + large_layout + ? 8 + : 3; + + int panel_bottom = + LINES - 4; + + int first_row = + panel_top + 2; + + int visible = + panel_bottom - + first_row; + + int key; + size_t index; + + if (visible < 1) { + return; + } + + erase(); + box( + stdscr, + 0, + 0 + ); + + if (large_layout) { + section_ascii_header( + ":: S Y N C S H O W ::" + ); + + focused_panel( + panel_top, + 2, + panel_bottom, + COLS - 3, + "DETAIL SYNCHRONISATION", + true + ); + } else { + draw_shell( + "TRAINLOG — Sync show", + "↑↓ défiler PgUp/PgDn page b/Échap retour" + ); + } + + for ( + index = 0U; + index < (size_t)visible && + offset + index < + line_count; + ++index + ) { + mvprintw( + first_row + + (int)index, + large_layout + ? 5 + : 4, + "%.*s", + COLS - + ( + large_layout + ? 10 + : 8 + ), + lines[ + offset + + index + ] + ); + } + + attron( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + mvprintw( + LINES - 2, + 2, + "%.*s", + COLS - 4, + "↑↓ défiler PgUp/PgDn page b/Échap retour" + ); + + attroff( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + refresh(); + key = getch(); + + if ( + key == 'b' || + key == 'B' || + key == 27 || + key == '\n' || + key == KEY_ENTER + ) { + return; + } + + if ( + key == KEY_UP && + offset > 0U + ) { + --offset; + } else if ( + key == KEY_DOWN && + offset + + (size_t)visible < + line_count + ) { + ++offset; + } else if ( + key == KEY_PPAGE + ) { + size_t jump = + (size_t)visible; + + offset = + offset > jump + ? offset - jump + : 0U; + } else if ( + key == KEY_NPAGE + ) { + size_t jump = + (size_t)visible; + + if ( + offset + jump < + line_count + ) { + offset += jump; + } + + if ( + line_count > + (size_t)visible && + offset + + (size_t)visible > + line_count + ) { + offset = + line_count - + (size_t)visible; + } + } + } +} + +static double sync_bytes_to_gib( + uint64_t bytes +) +{ + return + (double)bytes / + ( + 1024.0 * + 1024.0 * + 1024.0 + ); } static void screen_sync( @@ -10317,13 +9249,19 @@ static void screen_sync( size_t selected = 0U; int nav_selected = 5; int focus = 1; - TrainlogSyncOverview overview; - bool refresh_overview = true; + + TrainlogSyncDeviceInfo device; + TrainlogStatus probe_status = + TRAINLOG_STATUS_NOT_FOUND; + + bool refresh_device = true; + + (void)database; (void)memset( - &overview, + &device, 0, - sizeof(overview) + sizeof(device) ); for (;;) { @@ -10331,19 +9269,20 @@ static void screen_sync( history[SYNC_HISTORY_CAPACITY]; size_t history_count = 0U; + bool large_layout = COLS >= 100 && LINES >= 30; int key; - if (refresh_overview) { - sync_load_overview_silenced( - database, - &overview - ); + if (refresh_device) { + probe_status = + trainlog_sync_probe( + &device + ); - refresh_overview = false; + refresh_device = false; } sync_history_load( @@ -10361,12 +9300,18 @@ static void screen_sync( } erase(); - box(stdscr, 0, 0); + box( + stdscr, + 0, + 0 + ); if (large_layout) { size_t index; size_t top = 0U; + int history_top = 18; + int history_bottom = LINES - 4; @@ -10394,7 +9339,12 @@ static void screen_sync( false ); - if (overview.connected) { + if ( + probe_status == + TRAINLOG_STATUS_OK && + device.connected && + device.storage_ready + ) { mvprintw( 12, 5, @@ -10405,25 +9355,32 @@ static void screen_sync( 13, 5, "%s %s", - overview.device.vendor, - overview.device.model + device.device.vendor, + device.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 - ) - ); - } + mvprintw( + 14, + 5, + "Stockage interne : %.2f GiB libres / %.2f GiB", + sync_bytes_to_gib( + device.storage + .free_space_bytes + ), + sync_bytes_to_gib( + device.storage + .max_capacity_bytes + ) + ); + } else if ( + probe_status == + TRAINLOG_STATUS_CONFLICT + ) { + mvprintw( + 13, + 5, + "Service de synchronisation occupé." + ); } else { mvprintw( 13, @@ -10441,7 +9398,9 @@ static void screen_sync( focus == 1 ); - if (history_count == 0U) { + if ( + history_count == 0U + ) { mvprintw( history_top + 2, 5, @@ -10464,8 +9423,10 @@ static void screen_sync( for ( index = 0U; - index < (size_t)visible_rows && - top + index < history_count; + index < + (size_t)visible_rows && + top + index < + history_count; ++index ) { size_t absolute = @@ -10492,13 +9453,16 @@ static void screen_sync( row, 5, " %-16s %c %-*.*s ", - history[absolute].timestamp, - history[absolute].success + history[absolute] + .timestamp, + history[absolute] + .success ? '+' : '!', COLS - 28, COLS - 28, - history[absolute].summary + history[absolute] + .summary ); if ( @@ -10526,7 +9490,7 @@ static void screen_sync( 2, "%.*s", COLS - 4, - "Tab zone ←→ menu ↑↓ historique s synchroniser les 2 sens r actualiser b/Échap retour" + "Tab zone ←→ menu ↑↓ historique Entrée détail s synchroniser r actualiser b/Échap retour" ); attroff( @@ -10537,16 +9501,20 @@ static void screen_sync( } else { draw_shell( "TRAINLOG — Sync", - "s synchroniser les 2 sens r actualiser b/Échap retour" + "↑↓ historique Entrée détail s synchroniser r actualiser b/Échap retour" ); - if (overview.connected) { + if ( + probe_status == + TRAINLOG_STATUS_OK && + device.connected + ) { mvprintw( 4, 4, "✓ %s %s", - overview.device.vendor, - overview.device.model + device.device.vendor, + device.device.model ); } else { mvprintw( @@ -10556,7 +9524,9 @@ static void screen_sync( ); } - if (history_count == 0U) { + if ( + history_count == 0U + ) { mvprintw( 7, 4, @@ -10564,6 +9534,7 @@ static void screen_sync( ); } else { size_t index; + size_t limit = history_count < 8U ? history_count @@ -10574,17 +9545,42 @@ static void screen_sync( index < limit; ++index ) { + if ( + index == selected + ) { + attron( + A_REVERSE | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + } + mvprintw( 7 + (int)index, 4, "%-16s %c %.*s", - history[index].timestamp, - history[index].success + history[index] + .timestamp, + history[index] + .success ? '+' : '!', COLS - 25, - history[index].summary + history[index] + .summary ); + + if ( + index == selected + ) { + attroff( + A_REVERSE | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + } } } } @@ -10596,24 +9592,9 @@ static void screen_sync( key == 's' || key == 'S' ) { - TrainlogMobileImportReport report; - char import_output[2048]; - char message[256]; - uint64_t download_size = 0U; + TrainlogSyncReport report; 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 @@ -10622,59 +9603,26 @@ static void screen_sync( refresh(); status = - sync_bidirectional( - &overview, - &report, - import_output, - sizeof(import_output), - &download_size + trainlog_sync_run( + TRAINLOG_SYNC_TRIGGER_TUI, + false, + &report ); if ( status == - TRAINLOG_STATUS_OK + TRAINLOG_STATUS_OK && + report.success ) { - (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, + report.summary, 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, + report.error[0] != '\0' + ? report.error + : "Synchronisation échouée.", TRAINLOG_COLOR_ERROR ); } @@ -10682,7 +9630,7 @@ static void screen_sync( refresh(); (void)getch(); - refresh_overview = true; + refresh_device = true; continue; } @@ -10690,7 +9638,7 @@ static void screen_sync( key == 'r' || key == 'R' ) { - refresh_overview = true; + refresh_device = true; continue; } @@ -10721,7 +9669,9 @@ static void screen_sync( large_layout && focus == 0 ) { - if (key == KEY_LEFT) { + if ( + key == KEY_LEFT + ) { nav_selected = nav_selected > 0 ? nav_selected - 1 @@ -10753,6 +9703,26 @@ static void screen_sync( continue; } + if ( + history_count > 0U && + ( + key == '\n' || + key == KEY_ENTER + ) + ) { + if ( + history[selected] + .sync_id[0] != '\0' + ) { + screen_sync_run_detail( + history[selected] + .sync_id + ); + } + + continue; + } + if ( key == KEY_UP && history_count > 0U diff --git a/tui/tests/test_database.c b/tui/tests/test_database.c index 929353b..f7e9c9e 100644 --- a/tui/tests/test_database.c +++ b/tui/tests/test_database.c @@ -53,6 +53,7 @@ static bool test_generated_ids(void) { char first[TRAINLOG_GENERATED_ID_CAPACITY]; char second[TRAINLOG_GENERATED_ID_CAPACITY]; + char sync_id[TRAINLOG_GENERATED_ID_CAPACITY]; CHECK( trainlog_id_generate("ex", first, sizeof(first)) == @@ -67,6 +68,28 @@ static bool test_generated_ids(void) CHECK(strcmp(first, second) != 0); CHECK(first[3U + 14U] == '4'); + /* TRAINLOG_SYNC_ID_PREFIX_TEST */ + CHECK( + trainlog_id_generate( + "sy", + sync_id, + sizeof(sync_id) + ) == TRAINLOG_STATUS_OK + ); + + CHECK( + strncmp( + sync_id, + "sy_", + 3U + ) == 0 + ); + + CHECK( + sync_id[3U + 14U] == + '4' + ); + return true; } diff --git a/tui/tools/sync_once.c b/tui/tools/sync_once.c new file mode 100644 index 0000000..77e2cd5 --- /dev/null +++ b/tui/tools/sync_once.c @@ -0,0 +1,176 @@ +/** + * @file sync_once.c + * @brief Non-interactive entry point for the shared Trainlog sync engine. + */ + +#include +#include +#include + +#include "trainlog/sync.h" + +static bool parse_trigger( + const char *text, + TrainlogSyncTrigger *output +) +{ + if ( + text == NULL || + output == NULL + ) { + return false; + } + + if ( + strcmp( + text, + "tui" + ) == 0 + ) { + *output = + TRAINLOG_SYNC_TRIGGER_TUI; + + return true; + } + + if ( + strcmp( + text, + "android" + ) == 0 + ) { + *output = + TRAINLOG_SYNC_TRIGGER_ANDROID; + + return true; + } + + if ( + strcmp( + text, + "daemon" + ) == 0 + ) { + *output = + TRAINLOG_SYNC_TRIGGER_DAEMON; + + return true; + } + + return false; +} + +int main( + int argc, + char **argv +) +{ + bool request_only = false; + + TrainlogSyncTrigger trigger = + TRAINLOG_SYNC_TRIGGER_DAEMON; + + TrainlogSyncReport report; + TrainlogStatus status; + int index; + + for ( + index = 1; + index < argc; + ++index + ) { + if ( + strcmp( + argv[index], + "--request-only" + ) == 0 + ) { + request_only = true; + continue; + } + + if ( + strcmp( + argv[index], + "--trigger" + ) == 0 && + index + 1 < argc + ) { + ++index; + + if ( + !parse_trigger( + argv[index], + &trigger + ) + ) { + (void)fprintf( + stderr, + "invalid trigger\n" + ); + + return 64; + } + + continue; + } + + (void)fprintf( + stderr, + "usage: %s [--request-only] [--trigger tui|android|daemon]\n", + argv[0] + ); + + return 64; + } + + status = + trainlog_sync_run( + trigger, + request_only, + &report + ); + + if ( + request_only && + status == + TRAINLOG_STATUS_NOT_FOUND + ) { + (void)printf( + "SYNC_REQUEST=NONE\n" + ); + + return 3; + } + + if ( + status == + TRAINLOG_STATUS_OK && + report.success + ) { + (void)printf( + "SYNC_RUN=PASS\n" + "sync_id=%s\n" + "request_id=%s\n" + "summary=%s\n", + report.sync_id, + report.request_id, + report.summary + ); + + return 0; + } + + (void)fprintf( + stderr, + "SYNC_RUN=FAIL\n" + "status=%d\n" + "error=%s\n", + (int)status, + report.error[0] != '\0' + ? report.error + : "Erreur interne sans diagnostic." + ); + + return 2; +}