3427 lines
77 KiB
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;
|
|
}
|