trainlog/tui/src/sync.c

3427 lines
77 KiB
C

/**
* @file sync.c
* @brief Shared direct-MTP Trainlog synchronization engine.
*/
#include "trainlog/sync.h"
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#include "trainlog/id.h"
#include "trainlog/sync_history.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-v3.json";
static const char *const MOBILE_EXPORT_V2_NAME =
"trainlog-mobile-export-v2.json";
static const char *const MOBILE_EXPORT_V1_NAME =
"trainlog-mobile-export-v1.json";
static const char *const PC_MOBILE_EXPORT_NAME =
"trainlog-pc-mobile-export-v3.json";
static const char *const PC_CATALOG_NAME =
"trainlog-pc-catalog-v1.json";
static const char *const EQUIPMENT_ASSOCIATIONS_NAME =
"trainlog-equipment-associations-v2.json";
static const char *const MOBILE_EQUIPMENT_DEFINITIONS_NAME =
"trainlog-mobile-equipment-definitions-v1.json";
static const char *const PC_EQUIPMENT_DEFINITIONS_NAME =
"trainlog-pc-equipment-definitions-v1.json";
static const char *const EXERCISE_BODY_ZONES_NAME =
"trainlog-exercise-body-zones-v1.json";
static const char *const EXERCISE_ALIASES_NAME =
"trainlog-exercise-aliases-v1.json";
static const char *const TRAINING_FEEDBACK_NAME =
"trainlog-training-feedback-v2.json";
static const char *const TRAINING_FEEDBACK_V1_NAME =
"trainlog-training-feedback-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-v3.json";
static const char *const PC_MOBILE_EXPORT_LOCAL =
"/tmp/trainlog-pc-mobile-export-v3.json";
static const char *const PC_CATALOG_LOCAL =
"/tmp/trainlog-pc-catalog-v1.json";
static const char *const EQUIPMENT_ASSOCIATIONS_LOCAL =
"/tmp/trainlog-equipment-associations-v2.json";
static const char *const MOBILE_EQUIPMENT_DEFINITIONS_LOCAL =
"/tmp/trainlog-mobile-equipment-definitions-v1.json";
static const char *const PC_EQUIPMENT_DEFINITIONS_LOCAL =
"/tmp/trainlog-pc-equipment-definitions-v1.json";
static const char *const EQUIPMENT_DEFINITIONS_RESULT =
"/tmp/trainlog-equipment-definitions-result.txt";
static const char *const EXERCISE_BODY_ZONES_LOCAL =
"/tmp/trainlog-exercise-body-zones-v1.json";
static const char *const EXERCISE_ALIASES_LOCAL =
"/tmp/trainlog-exercise-aliases-v1.json";
static const char *const TRAINING_FEEDBACK_LOCAL =
"/tmp/trainlog-training-feedback-v2.json";
static const char *const EXERCISE_BODY_ZONES_RESULT =
"/tmp/trainlog-exercise-body-zones-result.txt";
static const char *const EXERCISE_ALIASES_RESULT =
"/tmp/trainlog-exercise-aliases-result.txt";
static const char *const TRAINING_FEEDBACK_RESULT =
"/tmp/trainlog-training-feedback-result.txt";
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";
static const char *const EQUIPMENT_ASSOCIATIONS_RESULT =
"/tmp/trainlog-equipment-associations-result.txt";
typedef struct SyncSilence {
int saved_stdout;
int saved_stderr;
int null_fd;
} SyncSilence;
static void sync_compose_diagnostic(
char *output,
size_t output_size,
const char *prefix,
const char *detail
)
{
size_t written = 0U;
if (output == NULL || output_size == 0U) {
return;
}
/* CONTRACT: diagnostics retain their specific context and as much of the
* underlying failure as fits. INVARIANT: output is always NUL-terminated,
* even when an external tool supplies the maximum-length error. */
while (prefix != NULL && prefix[written] != '\0' &&
written + 1U < output_size) {
output[written] = prefix[written];
written += 1U;
}
if (detail != NULL) {
size_t detail_index = 0U;
while (detail[detail_index] != '\0' &&
written + 1U < output_size) {
output[written] = detail[detail_index];
written += 1U;
detail_index += 1U;
}
}
output[written] = '\0';
}
TrainlogSyncDirectionPlan trainlog_sync_direction_plan(
TrainlogSyncDirection direction
)
{
TrainlogSyncDirectionPlan plan = {false, false};
if (direction == TRAINLOG_SYNC_ANDROID_TO_PC ||
direction == TRAINLOG_SYNC_BIDIRECTIONAL) {
plan.receive_android = true;
}
if (direction == TRAINLOG_SYNC_PC_TO_ANDROID ||
direction == TRAINLOG_SYNC_BIDIRECTIONAL) {
plan.publish_android = true;
}
return plan;
}
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 bool sync_android_artifact_ordinal(
const char *name,
const char *canonical_name,
uint32_t *output_ordinal
)
{
static const char suffix[] = ").json";
static const char extension[] = ".json";
size_t suffix_length;
size_t extension_length;
size_t canonical_length;
size_t stem_length;
size_t name_length;
size_t index;
uint32_t ordinal = 0U;
if (name == NULL || canonical_name == NULL || output_ordinal == NULL) {
return false;
}
if (strcmp(name, canonical_name) == 0) {
*output_ordinal = 0U;
return true;
}
suffix_length = sizeof(suffix) - 1U;
extension_length = sizeof(extension) - 1U;
canonical_length = strlen(canonical_name);
if (canonical_length <= extension_length ||
strcmp(canonical_name + canonical_length - extension_length,
extension) != 0) {
return false;
}
stem_length = canonical_length - extension_length;
name_length = strlen(name);
if (name_length < stem_length + 3U + suffix_length ||
strncmp(name, canonical_name, stem_length) != 0 ||
name[stem_length] != ' ' ||
name[stem_length + 1U] != '(' ||
strcmp(name + name_length - suffix_length, suffix) != 0) {
return false;
}
for (index = stem_length + 2U;
index < name_length - suffix_length;
++index) {
unsigned int digit;
if (name[index] < '0' || name[index] > '9') {
return false;
}
digit = (unsigned int)(name[index] - '0');
if (ordinal > (UINT32_MAX - digit) / 10U) {
return false;
}
ordinal = ordinal * 10U + digit;
}
if (ordinal == 0U) {
return false;
}
*output_ordinal = ordinal;
return true;
}
bool trainlog_sync_select_android_artifact(
const TrainlogMtpEntry *entries,
size_t count,
const char *canonical_name,
size_t *output_index
)
{
bool found = false;
uint64_t best_modified = 0U;
uint32_t best_ordinal = 0U;
uint32_t best_item_id = 0U;
size_t best_index = 0U;
size_t index;
if (entries == NULL || count == 0U || canonical_name == NULL ||
canonical_name[0] == '\0' || output_index == NULL) {
return false;
}
for (index = 0U; index < count; ++index) {
uint32_t ordinal;
const TrainlogMtpEntry *entry = &entries[index];
if (entry->folder ||
!sync_android_artifact_ordinal(entry->name, canonical_name,
&ordinal)) {
continue;
}
if (!found ||
entry->modification_unix_seconds > best_modified ||
(entry->modification_unix_seconds == best_modified &&
ordinal > best_ordinal) ||
(entry->modification_unix_seconds == best_modified &&
ordinal == best_ordinal && entry->item_id < best_item_id)) {
found = true;
best_modified = entry->modification_unix_seconds;
best_ordinal = ordinal;
best_item_id = entry->item_id;
best_index = index;
}
}
if (!found) {
return false;
}
*output_index = best_index;
return true;
}
bool trainlog_sync_select_mobile_export(
const TrainlogMtpEntry *entries,
size_t count,
size_t *output_index
)
{
return trainlog_sync_select_android_artifact(entries, count,
MOBILE_EXPORT_NAME,
output_index);
}
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_receive_current_android_artifact(
const TrainlogSyncDeviceInfo *device,
uint32_t folder_id,
const char *canonical_name,
const char *local_path,
uint64_t *output_size
)
{
TrainlogMtpEntry entries[SYNC_ENTRY_CAPACITY];
size_t count = 0U;
size_t selected_index;
TrainlogStatus status;
if (device == NULL || canonical_name == NULL || local_path == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = trainlog_mtp_list_folder(
device->device.bus_number,
device->device.device_number,
device->storage.storage_id,
folder_id,
entries,
SYNC_ENTRY_CAPACITY,
&count
);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
if (!trainlog_sync_select_android_artifact(entries, count, canonical_name,
&selected_index)) {
return TRAINLOG_STATUS_NOT_FOUND;
}
status = trainlog_mtp_receive_file(
device->device.bus_number,
device->device.device_number,
entries[selected_index].item_id,
local_path
);
if (status == TRAINLOG_STATUS_OK && output_size != NULL) {
*output_size = entries[selected_index].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 *database_path,
const char *mobile_export_path,
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 ||
(mobile_export_path != NULL && database_path == NULL)
) {
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
);
if (database_path != NULL && mobile_export_path != NULL) {
/* CONTRACT: reconciliation-aware companion importers may consume
* the exact retained V2/V3 snapshot as stable-entry identity
* proof. argv stays bounded and shell-free. */
execlp(
"python3",
"python3",
tool,
argument,
"--database",
database_path,
"--mobile-export",
mobile_export_path,
(char *)NULL
);
} else if (database_path != NULL) {
/* CONTRACT: helpers which mutate the desktop store receive its
* explicit XDG-resolved path. They must never infer a different
* user's database from Python's process environment. */
execlp(
"python3",
"python3",
tool,
argument,
"--database",
database_path,
(char *)NULL
);
} else {
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 const char *sync_direction_text(
TrainlogSyncDirection direction
)
{
switch (direction) {
case TRAINLOG_SYNC_ANDROID_TO_PC:
return "android_to_pc";
case TRAINLOG_SYNC_PC_TO_ANDROID:
return "pc_to_android";
case TRAINLOG_SYNC_BIDIRECTIONAL:
return "bidirectional";
default:
return NULL;
}
}
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_history_write_summary(
FILE *file,
const char *summary
)
{
size_t index;
if (file == NULL || summary == NULL) {
return false;
}
for (
index = 0U;
index < TRAINLOG_SYNC_SUMMARY_MAX && summary[index] != '\0';
++index
) {
const char value =
summary[index] == '\t' ||
summary[index] == '\r' ||
summary[index] == '\n'
? ' '
: summary[index];
if (fputc((unsigned char)value, file) == EOF) {
return false;
}
}
return true;
}
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;
}
bool trainlog_sync_record_local_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;
const char *direction_code;
const char *direction_text;
const char *direction_label;
TrainlogSyncHistoryEntry direction_entry;
(void)memset(
&direction_entry,
0,
sizeof(direction_entry)
);
if (report != NULL) {
direction_entry.direction_known = true;
direction_entry.direction = report->direction;
}
direction_code =
report == NULL
? NULL
: trainlog_sync_history_direction_code(
report->direction
);
direction_text =
report == NULL
? NULL
: sync_direction_text(
report->direction
);
direction_label =
report == NULL
? NULL
: trainlog_sync_history_direction_label(
&direction_entry
);
if (
report == NULL ||
direction_code == NULL ||
direction_text == NULL ||
direction_label == 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 \"direction\":",
json_file
);
if (
!sync_json_write_escaped(
json_file,
direction_text
)
) {
(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"
"Direction : %s\n"
"Début : %s\n"
"État : %s\n",
report->sync_id,
sync_trigger_text(
trigger
),
direction_label,
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;
}
/* CONTRACT: direction is a dedicated field because failure summaries may
* be neutral or mention a different phase than the selected operation. */
written = fprintf(
history_file,
"%s\t%s\t%d\t%s\t",
report->sync_id,
local_timestamp,
report->success
? 1
: 0,
direction_code
);
if (
written < 0 ||
!sync_history_write_summary(
history_file,
report->summary
) ||
fputc('\n', history_file) == EOF
) {
(void)fclose(history_file);
return false;
}
return
fclose(
history_file
) == 0;
}
void trainlog_sync_build_summary(
TrainlogSyncReport *report
)
{
if (report == NULL) {
return;
}
if (report->success) {
if (report->direction == TRAINLOG_SYNC_ANDROID_TO_PC) {
(void)snprintf(report->summary, sizeof(report->summary),
"Android→PC +%zu séance(s), +%zu exercice(s), +%zu mesure(s), +%zu équipement(s)",
report->sessions_imported,
report->exercises_imported,
report->body_imported, report->equipment_definitions_imported);
} else if (report->direction == TRAINLOG_SYNC_PC_TO_ANDROID) {
(void)snprintf(report->summary, sizeof(report->summary),
"PC→Android catalogue %zu exercice(s), définitions/séances/mesures publiées",
report->catalog_published);
} else {
(void)snprintf(report->summary, sizeof(report->summary),
"PC↔Android import +%zu séance(s), +%zu exercice(s), +%zu mesure(s), +%zu équipement(s) · catalogue %zu publié(s)",
report->sessions_imported,
report->exercises_imported,
report->body_imported, report->equipment_definitions_imported,
report->catalog_published);
}
} else {
sync_compose_diagnostic(
report->summary,
sizeof(report->summary),
"",
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,
TrainlogSyncDirection direction,
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
];
char database_path[
PATH_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;
bool mobile_export_has_companions = false;
bool retained_mobile_export_is_v2 = false;
if (output == NULL || direction < TRAINLOG_SYNC_ANDROID_TO_PC ||
direction > TRAINLOG_SYNC_BIDIRECTIONAL ||
(require_request && direction != TRAINLOG_SYNC_BIDIRECTIONAL)) {
return
TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)memset(
output,
0,
sizeof(*output)
);
output->direction = direction;
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) {
/* WHY: MediaStore cannot always reopen an older MTP-created object by
* canonical name, so Android may publish the next request as the exact
* scoped-storage collision form " (N).json". CONTRACT: select the
* newest canonical-or-suffixed request deterministically, exactly as
* for the other Android-originated artifacts; request_id replay
* protection below remains the trigger authority. */
status =
sync_receive_current_android_artifact(
&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;
/* INVARIANT: every local import/export in one run addresses the same
* XDG-resolved database as the TUI, never a Python-derived fallback. */
if (!sync_data_file("trainlog.db", database_path,
sizeof(database_path))) {
(void)snprintf(output->error, sizeof(output->error),
"Synchronisation : chemin de base introuvable.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) {
goto outbound;
}
/* WHY: aliases must exist before the frozen session/catalog snapshot is
* interpreted, so a retired source ID resolves without changing V3. */
status = sync_receive_current_android_artifact(&device, folder_id,
EXERCISE_ALIASES_NAME, EXERCISE_ALIASES_LOCAL, &ignored_size);
if (status == TRAINLOG_STATUS_OK) {
status = sync_run_python_tool("import_exercise_aliases.py",
EXERCISE_ALIASES_LOCAL, database_path, NULL, EXERCISE_ALIASES_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_ALIAS_IMPORT=PASS") == NULL) {
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
sync_compose_diagnostic(output->error, sizeof(output->error),
"Android→PC : alias exercices : ",
useful[0] != '\0' ? useful : "import échoué");
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
} else if (status != TRAINLOG_STATUS_NOT_FOUND) {
(void)snprintf(output->error, sizeof(output->error),
"Android→PC : lecture alias exercices échouée.");
final_status = status;
goto finalize;
}
/* Definitions must reconcile before either V3/V2 reference artifact. A
* missing file is accepted only for historic snapshots with no custom ID. */
status = sync_receive_current_android_artifact(
&device,
folder_id,
MOBILE_EQUIPMENT_DEFINITIONS_NAME,
MOBILE_EQUIPMENT_DEFINITIONS_LOCAL,
&ignored_size
);
if (status == TRAINLOG_STATUS_OK) {
status = sync_run_python_tool("import_equipment_definitions.py",
MOBILE_EQUIPMENT_DEFINITIONS_LOCAL,
database_path,
NULL,
EQUIPMENT_DEFINITIONS_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EQUIPMENT_DEFINITIONS_IMPORT=PASS") == NULL) {
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
sync_compose_diagnostic(
output->error,
sizeof(output->error),
"Android→PC : définitions équipement : ",
useful[0] != '\0' ? useful : "import échoué"
);
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
output->equipment_definitions_imported =
sync_report_value(tool_output, "definitions_imported");
output->equipment_definitions_skipped =
sync_report_value(tool_output, "definitions_skipped");
} else if (status != TRAINLOG_STATUS_NOT_FOUND) {
(void)snprintf(output->error, sizeof(output->error),
"Android→PC : lecture définitions équipement échouée.");
final_status = status;
goto finalize;
}
status = sync_receive_current_android_artifact(
&device,
folder_id,
MOBILE_EXPORT_NAME,
MOBILE_EXPORT_LOCAL,
&ignored_size
);
if (status == TRAINLOG_STATUS_NOT_FOUND) {
/* CONTRACT: selected V3 content is authoritative. Only absence permits
* V2 selection; an invalid V3 is imported and fails without fallback. */
status = sync_receive_current_android_artifact(
&device, folder_id, MOBILE_EXPORT_V2_NAME,
MOBILE_EXPORT_LOCAL, &ignored_size);
retained_mobile_export_is_v2 = status == TRAINLOG_STATUS_OK;
}
mobile_export_has_companions = status == TRAINLOG_STATUS_OK;
if (status == TRAINLOG_STATUS_NOT_FOUND) {
status = sync_receive_named(&device, folder_id, MOBILE_EXPORT_V1_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,
database_path,
NULL,
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)
);
sync_compose_diagnostic(
output->error,
sizeof(output->error),
"Android→PC : ",
useful[0] != '\0'
? useful
: "import mobile échoué"
);
final_status =
TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
sync_parse_import_report(
tool_output,
output
);
/* CONTRACT: body zones are one directional-neutral companion. Historic
* V2 publishers may omit it; when present beside V3/V2 it is applied after
* exercise definitions above established every ID or a source-V2-proven
* normalized-name alias. The helper consumes the retained mobile snapshot
* solely as reconciliation proof; it never infers identity from a name. */
status = mobile_export_has_companions
? sync_receive_current_android_artifact(&device, folder_id,
EXERCISE_BODY_ZONES_NAME, EXERCISE_BODY_ZONES_LOCAL, &ignored_size)
: TRAINLOG_STATUS_NOT_FOUND;
if (status == TRAINLOG_STATUS_OK) {
status = sync_run_python_tool("import_exercise_body_zones.py",
EXERCISE_BODY_ZONES_LOCAL, database_path,
retained_mobile_export_is_v2 ? MOBILE_EXPORT_LOCAL : NULL,
EXERCISE_BODY_ZONES_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_BODY_ZONES_IMPORT=PASS") == NULL) {
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
sync_compose_diagnostic(output->error, sizeof(output->error),
"Android→PC : zones corporelles : ",
useful[0] != '\0' ? useful : "import échoué");
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
} else if (status != TRAINLOG_STATUS_NOT_FOUND) {
(void)snprintf(output->error, sizeof(output->error),
"Android→PC : lecture zones corporelles échouée.");
final_status = status;
goto finalize;
}
/* V1 exports carry no equipment signal. A V3/V2 companion beside a historic
* V1 snapshot belongs to another generation and must not be applied. Its
* absence therefore preserves existing associations rather than clearing
* them. Scoped storage may suffix every current Android publication, so the
* path selects its newest candidate instead of an older canonical object. */
status = mobile_export_has_companions
? sync_receive_current_android_artifact(
&device,
folder_id,
EQUIPMENT_ASSOCIATIONS_NAME,
EQUIPMENT_ASSOCIATIONS_LOCAL,
&ignored_size
)
: TRAINLOG_STATUS_NOT_FOUND;
if (status == TRAINLOG_STATUS_OK) {
char useful[
TRAINLOG_SYNC_ERROR_MAX + 1U
];
status = sync_run_python_tool("import_equipment_associations.py",
EQUIPMENT_ASSOCIATIONS_LOCAL,
database_path,
MOBILE_EXPORT_LOCAL,
EQUIPMENT_ASSOCIATIONS_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS") == NULL) {
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
sync_compose_diagnostic(
output->error,
sizeof(output->error),
"Android→PC : import équipement : ",
useful[0] != '\0'
? useful
: "échec sans diagnostic du script"
);
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
} else if (status != TRAINLOG_STATUS_NOT_FOUND) {
(void)snprintf(output->error, sizeof(output->error),
"Android→PC : lecture extension équipement échouée.");
final_status = status;
goto finalize;
}
/* CONTRACT: feedback references are resolved only after aliases,
* catalogue/session snapshots, and occurrence equipment reconciliation. */
status = sync_receive_current_android_artifact(&device, folder_id,
TRAINING_FEEDBACK_NAME, TRAINING_FEEDBACK_LOCAL, &ignored_size);
if (status == TRAINLOG_STATUS_NOT_FOUND) {
/* V2 is authoritative, but a deployed V1-only peer remains readable.
* The importer turns its immutable raw_text into fr0_<root-id> and
* cannot erase locally known revisions. */
status = sync_receive_current_android_artifact(&device, folder_id,
TRAINING_FEEDBACK_V1_NAME, TRAINING_FEEDBACK_LOCAL, &ignored_size);
}
if (status == TRAINLOG_STATUS_OK) {
status = sync_run_python_tool("import_training_feedback.py",
TRAINING_FEEDBACK_LOCAL, database_path, NULL, TRAINING_FEEDBACK_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "TRAINING_FEEDBACK_IMPORT=PASS") == NULL) {
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
sync_compose_diagnostic(output->error, sizeof(output->error),
"Android→PC : ressentis : ", useful[0] ? useful : "import échoué");
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
} else if (status != TRAINLOG_STATUS_NOT_FOUND) {
(void)snprintf(output->error, sizeof(output->error),
"Android→PC : lecture ressentis échouée.");
final_status = status;
goto finalize;
}
if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) {
output->success = true;
final_status = TRAINLOG_STATUS_OK;
goto finalize;
}
outbound:
status = sync_run_python_tool("export_exercise_aliases.py",
EXERCISE_ALIASES_LOCAL, database_path, NULL, EXERCISE_ALIASES_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_ALIAS_EXPORT=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : export alias exercices échoué.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
status = sync_publish_named(&device, folder_id, EXERCISE_ALIASES_LOCAL,
EXERCISE_ALIASES_NAME);
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : publication alias exercices échouée.");
final_status = status;
goto finalize;
}
status = sync_run_python_tool("export_training_feedback.py",
TRAINING_FEEDBACK_LOCAL, database_path, NULL, TRAINING_FEEDBACK_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "TRAINING_FEEDBACK_EXPORT=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : export ressentis échoué.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
status = sync_publish_named(&device, folder_id, TRAINING_FEEDBACK_LOCAL,
TRAINING_FEEDBACK_NAME);
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : publication ressentis échouée.");
final_status = status;
goto finalize;
}
status = sync_run_python_tool("export_equipment_definitions.py",
PC_EQUIPMENT_DEFINITIONS_LOCAL,
database_path,
NULL,
EQUIPMENT_DEFINITIONS_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EQUIPMENT_DEFINITIONS_EXPORT=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : export définitions équipement échoué.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
status = sync_publish_named(&device, folder_id,
PC_EQUIPMENT_DEFINITIONS_LOCAL,
PC_EQUIPMENT_DEFINITIONS_NAME);
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : publication définitions équipement échouée.");
final_status = status;
goto finalize;
}
status =
sync_run_python_tool(
"export_pc_catalog.py",
PC_CATALOG_LOCAL,
database_path,
NULL,
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)
);
sync_compose_diagnostic(
output->error,
sizeof(output->error),
"PC→Android : ",
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),
"PC→Android : publication MTP du catalogue échouée.");
final_status = status;
goto finalize;
}
status = sync_run_python_tool("export_exercise_body_zones.py",
EXERCISE_BODY_ZONES_LOCAL, database_path, NULL,
EXERCISE_BODY_ZONES_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_BODY_ZONES_EXPORT=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : export zones corporelles échoué.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
status = sync_publish_named(&device, folder_id, EXERCISE_BODY_ZONES_LOCAL,
EXERCISE_BODY_ZONES_NAME);
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : publication zones corporelles échouée.");
final_status = status;
goto finalize;
}
/* WHY: a new custom exercise has no common sync ancestor yet. Only after
* the exact companion is durably visible to the peer may the publisher
* acknowledge that snapshot as its baseline. Reusing the strict importer
* records equal state and can never union secondary zones. */
status = sync_run_python_tool("import_exercise_body_zones.py",
EXERCISE_BODY_ZONES_LOCAL, database_path, NULL,
EXERCISE_BODY_ZONES_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_BODY_ZONES_IMPORT=PASS") == NULL) {
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
sync_compose_diagnostic(output->error, sizeof(output->error),
"PC→Android : baseline zones corporelles : ",
useful[0] != '\0' ? useful : "enregistrement échoué");
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
status = sync_run_python_tool("export_pc_mobile.py", PC_MOBILE_EXPORT_LOCAL,
database_path,
NULL,
PC_CATALOG_RESULT, tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "PC_MOBILE_EXPORT=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error), "PC→Android : export séances V3 échoué.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
status = sync_publish_named(&device, folder_id, PC_MOBILE_EXPORT_LOCAL, PC_MOBILE_EXPORT_NAME);
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf(output->error, sizeof(output->error), "PC→Android : publication séances V3 échouée.");
final_status = status;
goto finalize;
}
status = sync_run_python_tool("export_equipment_associations.py",
EQUIPMENT_ASSOCIATIONS_LOCAL,
database_path,
NULL,
EQUIPMENT_ASSOCIATIONS_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EQUIPMENT_ASSOCIATIONS_EXPORT=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : export équipement échoué.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
status = sync_publish_named(&device, folder_id, EQUIPMENT_ASSOCIATIONS_LOCAL,
EQUIPMENT_ASSOCIATIONS_NAME);
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : publication équipement échouée.");
final_status = status;
goto finalize;
}
output->success = true;
final_status =
TRAINLOG_STATUS_OK;
finalize:
trainlog_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;
trainlog_sync_build_summary(
output
);
}
}
(void)trainlog_sync_record_local_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;
}