From 8bb90ed05a029a81b125812e9677350faad0dd61 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 6 Aug 2026 19:35:58 +0200 Subject: [PATCH] feat: add asynchronous cancellable image import --- README.md | 2 + include/lardon3d/import.h | 34 ++++ include/lardon3d/import_task.h | 44 +++++ include/lardon3d/layout.h | 2 + meson.build | 18 +- src/import.c | 225 ++++++++++++++++++++--- src/import_task.c | 229 +++++++++++++++++++++++ src/layout.c | 76 +++++++- src/tui.c | 134 ++++++++++++-- tests/test_import_task.c | 324 +++++++++++++++++++++++++++++++++ 10 files changed, 1044 insertions(+), 44 deletions(-) create mode 100644 include/lardon3d/import_task.h create mode 100644 src/import_task.c create mode 100644 tests/test_import_task.c diff --git a/README.md b/README.md index 713e879..b4391c0 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,5 @@ La variable `LARDON3D_PROJECTS_ROOT` permet de choisir un autre répertoire raci L'import accepte les images JPEG, PNG, TIFF et HEIC présentes directement dans le dossier choisi, sans parcourir ses sous-dossiers. Elles sont copiées vers `images/originals` et répertoriées dans `images/manifest.tsv`. +L'import s'exécute en arrière-plan afin que la TUI reste réactive. Pendant une +opération, la touche `C` demande son annulation. diff --git a/include/lardon3d/import.h b/include/lardon3d/import.h index 6983075..a075e85 100644 --- a/include/lardon3d/import.h +++ b/include/lardon3d/import.h @@ -13,10 +13,44 @@ typedef struct { size_t ignored; } Lardon3DImportResult; +typedef struct { + size_t total; + size_t processed; + size_t copied; + size_t already_present; + size_t ignored; + const char *message; +} Lardon3DImportProgress; + +typedef bool (*Lardon3DImportCancelled)(void *context); +typedef void (*Lardon3DImportProgressed)( + void *context, + const Lardon3DImportProgress *progress +); + +typedef struct { + void *context; + Lardon3DImportCancelled is_cancelled; + Lardon3DImportProgressed progressed; +} Lardon3DImportControl; + +typedef enum { + LARDON3D_IMPORT_FAILED = 0, + LARDON3D_IMPORT_SUCCEEDED, + LARDON3D_IMPORT_CANCELLED +} Lardon3DImportOutcome; + bool lardon3d_import_directory( Lardon3DAppState *state, const char *source_directory, Lardon3DImportResult *result ); +Lardon3DImportOutcome lardon3d_import_directory_controlled( + Lardon3DAppState *state, + const char *source_directory, + Lardon3DImportResult *result, + const Lardon3DImportControl *control +); + #endif diff --git a/include/lardon3d/import_task.h b/include/lardon3d/import_task.h new file mode 100644 index 0000000..05beddb --- /dev/null +++ b/include/lardon3d/import_task.h @@ -0,0 +1,44 @@ +#ifndef LARDON3D_IMPORT_TASK_H +#define LARDON3D_IMPORT_TASK_H + +#include +#include + +#include + +typedef enum { + LARDON3D_IMPORT_TASK_IDLE = 0, + LARDON3D_IMPORT_TASK_RUNNING, + LARDON3D_IMPORT_TASK_SUCCEEDED, + LARDON3D_IMPORT_TASK_CANCELLED, + LARDON3D_IMPORT_TASK_FAILED +} Lardon3DImportTaskStatus; + +typedef struct { + Lardon3DImportTaskStatus status; + size_t total; + size_t processed; + size_t copied; + size_t already_present; + size_t ignored; + char message[256]; +} Lardon3DImportTaskSnapshot; + +typedef struct Lardon3DImportTask Lardon3DImportTask; + +Lardon3DImportTask *lardon3d_import_task_create(void); +bool lardon3d_import_task_start( + Lardon3DImportTask *task, + const Lardon3DAppState *state, + const char *source_directory +); +void lardon3d_import_task_request_cancel(Lardon3DImportTask *task); +bool lardon3d_import_task_snapshot( + Lardon3DImportTask *task, + Lardon3DImportTaskSnapshot *snapshot +); +bool lardon3d_import_task_is_finished(Lardon3DImportTask *task); +bool lardon3d_import_task_join(Lardon3DImportTask *task); +void lardon3d_import_task_destroy(Lardon3DImportTask *task); + +#endif diff --git a/include/lardon3d/layout.h b/include/lardon3d/layout.h index c9a75f3..e65ceed 100644 --- a/include/lardon3d/layout.h +++ b/include/lardon3d/layout.h @@ -2,11 +2,13 @@ #define LARDON3D_LAYOUT_H #include +#include void lardon3d_layout_draw( const Lardon3DAppState *state, const char *input_text, const char *input_label, + const Lardon3DImportTaskSnapshot *import_snapshot, int rows, int cols ); diff --git a/meson.build b/meson.build index 1c592a4..71f1f6a 100644 --- a/meson.build +++ b/meson.build @@ -19,6 +19,7 @@ add_project_arguments( ) ncursesw = dependency('ncursesw', required: true) +threads = dependency('threads') executable( 'lardon3d', @@ -29,10 +30,11 @@ executable( 'src/tui.c', 'src/layout.c', 'src/import.c', + 'src/import_task.c', 'src/project.c', ], include_directories: include_directories('include'), - dependencies: [ncursesw], + dependencies: [ncursesw, threads], ) import_test = executable( @@ -46,3 +48,17 @@ import_test = executable( ) test('import', import_test) + +import_task_test = executable( + 'test-import-task', + sources: [ + 'tests/test_import_task.c', + 'src/app_state.c', + 'src/import.c', + 'src/import_task.c', + ], + include_directories: include_directories('include'), + dependencies: [threads], +) + +test('import-task', import_task_test, timeout: 30) diff --git a/src/import.c b/src/import.c index bf7b914..134bfe1 100644 --- a/src/import.c +++ b/src/import.c @@ -37,6 +37,13 @@ typedef struct { size_t capacity; } CandidateList; +typedef enum { + COPY_IMAGE_ERROR = -1, + COPY_IMAGE_COLLISION = 0, + COPY_IMAGE_CREATED = 1, + COPY_IMAGE_CANCELLED = 2, +} CopyImageOutcome; + static void set_status(Lardon3DAppState *state, const char *message) { @@ -48,6 +55,37 @@ set_status(Lardon3DAppState *state, const char *message) ); } +static bool +import_is_cancelled(const Lardon3DImportControl *control) +{ + return control && control->is_cancelled + && control->is_cancelled(control->context); +} + +static void +publish_progress( + const Lardon3DImportControl *control, + const Lardon3DImportResult *result, + size_t processed, + const char *message +) +{ + if (!control || !control->progressed) { + return; + } + const Lardon3DImportProgress progress = { + .total = result->admissible_found, + .processed = processed <= result->admissible_found + ? processed + : result->admissible_found, + .copied = result->copied, + .already_present = result->already_present, + .ignored = result->ignored, + .message = message, + }; + control->progressed(control->context, &progress); +} + static bool join_path( char destination[PATH_MAX], @@ -416,7 +454,8 @@ static int copy_image( const char *source_path, const char *destination_path, - off_t *destination_size + off_t *destination_size, + const Lardon3DImportControl *control ) { int source = open(source_path, O_RDONLY | O_NOFOLLOW); @@ -445,8 +484,14 @@ copy_image( } bool success = true; + bool cancelled = false; char buffer[64 * 1024]; for (;;) { + if (import_is_cancelled(control)) { + cancelled = true; + success = false; + break; + } ssize_t read_count = read(source, buffer, sizeof(buffer)); if (read_count == 0) { break; @@ -461,6 +506,11 @@ copy_image( ssize_t written_total = 0; while (written_total < read_count) { + if (import_is_cancelled(control)) { + cancelled = true; + success = false; + break; + } ssize_t written = write( destination, buffer + written_total, @@ -491,11 +541,11 @@ copy_image( } if (!success) { (void)unlink(destination_path); - return -1; + return cancelled ? COPY_IMAGE_CANCELLED : COPY_IMAGE_ERROR; } *destination_size = source_info.st_size; - return 1; + return COPY_IMAGE_CREATED; } static bool @@ -504,7 +554,9 @@ analyze_source_directory( const char *absolute_source, const char *originals_path, Lardon3DImportResult *result, - CandidateList *candidates + CandidateList *candidates, + const Lardon3DImportControl *control, + bool *cancelled ) { DIR *directory = opendir(absolute_source); @@ -515,6 +567,10 @@ analyze_source_directory( bool success = true; for (;;) { + if (import_is_cancelled(control)) { + *cancelled = true; + break; + } errno = 0; struct dirent *entry = readdir(directory); if (!entry) { @@ -600,7 +656,7 @@ rollback_created_files( return removed; } -static bool +static void fail_import( Lardon3DAppState *state, Lardon3DImportResult *result, @@ -638,47 +694,80 @@ fail_import( reason ); } - return false; } -bool -lardon3d_import_directory( +static Lardon3DImportOutcome +cancel_import( + Lardon3DAppState *state, + Lardon3DImportResult *result, + size_t processed, + ManifestWriter *manifest, + CandidateList *candidates, + const char *originals_path, + const Lardon3DImportControl *control +) +{ + manifest_abort(manifest); + size_t removed = rollback_created_files(candidates, originals_path); + result->copied -= removed; + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Import annulé : %zu sur %zu fichiers traités.", + processed, + result->admissible_found + ); + publish_progress(control, result, processed, state->status_message); + free(candidates->items); + return LARDON3D_IMPORT_CANCELLED; +} + +Lardon3DImportOutcome +lardon3d_import_directory_controlled( Lardon3DAppState *state, const char *source_directory, - Lardon3DImportResult *result + Lardon3DImportResult *result, + const Lardon3DImportControl *control ) { if (!state || !result) { - return false; + return LARDON3D_IMPORT_FAILED; } *result = (Lardon3DImportResult) {0}; + publish_progress(control, result, 0, "Analyse du dossier source..."); if (!state->project_loaded) { set_status(state, "Aucun projet chargé."); - return false; + publish_progress(control, result, 0, state->status_message); + return LARDON3D_IMPORT_FAILED; } char trimmed_source[PATH_MAX]; char absolute_source[PATH_MAX]; if (!trim_source_path(state, source_directory, trimmed_source)) { - return false; + publish_progress(control, result, 0, state->status_message); + return LARDON3D_IMPORT_FAILED; } if (!resolve_source_path(state, trimmed_source, absolute_source)) { - return false; + publish_progress(control, result, 0, state->status_message); + return LARDON3D_IMPORT_FAILED; } if (has_forbidden_manifest_character(absolute_source)) { set_status(state, "Erreur : chemin source incompatible avec le manifeste."); - return false; + publish_progress(control, result, 0, state->status_message); + return LARDON3D_IMPORT_FAILED; } struct stat source_directory_info; if (lstat(absolute_source, &source_directory_info) != 0) { set_status(state, "Erreur : dossier source inexistant."); - return false; + publish_progress(control, result, 0, state->status_message); + return LARDON3D_IMPORT_FAILED; } if (!S_ISDIR(source_directory_info.st_mode)) { set_status(state, "Erreur : la source n'est pas un dossier."); - return false; + publish_progress(control, result, 0, state->status_message); + return LARDON3D_IMPORT_FAILED; } char images_path[PATH_MAX]; @@ -686,35 +775,62 @@ lardon3d_import_directory( if (!join_path(images_path, state->project_path, "images") || !join_path(originals_path, images_path, "originals")) { set_status(state, "Erreur : chemin du projet trop long."); - return false; + publish_progress(control, result, 0, state->status_message); + return LARDON3D_IMPORT_FAILED; } CandidateList candidates = {0}; + bool analysis_cancelled = false; if (!analyze_source_directory( state, absolute_source, originals_path, result, - &candidates + &candidates, + control, + &analysis_cancelled )) { free(candidates.items); - return false; + publish_progress(control, result, 0, state->status_message); + return LARDON3D_IMPORT_FAILED; + } + if (analysis_cancelled) { + free(candidates.items); + set_status(state, "Import annulé : 0 fichier traité."); + publish_progress(control, result, 0, state->status_message); + return LARDON3D_IMPORT_CANCELLED; + } + publish_progress(control, result, 0, "Import en cours..."); + + if (import_is_cancelled(control)) { + free(candidates.items); + set_status(state, "Import annulé : 0 fichier traité."); + publish_progress(control, result, 0, state->status_message); + return LARDON3D_IMPORT_CANCELLED; } if (!ensure_originals_directory(state, images_path, originals_path)) { free(candidates.items); - return false; + publish_progress(control, result, 0, state->status_message); + return LARDON3D_IMPORT_FAILED; } ManifestWriter manifest; if (!manifest_begin(state, images_path, &manifest)) { free(candidates.items); - return false; + publish_progress(control, result, 0, state->status_message); + return LARDON3D_IMPORT_FAILED; } bool success = true; + bool cancelled = false; + size_t processed = 0; const char *failure_reason = NULL; for (size_t index = 0; index < candidates.count; ++index) { + if (import_is_cancelled(control)) { + cancelled = true; + break; + } ImportCandidate *candidate = &candidates.items[index]; char source_path[PATH_MAX]; char destination_path[PATH_MAX]; @@ -730,13 +846,22 @@ lardon3d_import_directory( } off_t size = 0; - int copied = copy_image(source_path, destination_path, &size); - if (copied < 0) { + int copied = copy_image( + source_path, + destination_path, + &size, + control + ); + if (copied == COPY_IMAGE_CANCELLED) { + cancelled = true; + break; + } + if (copied == COPY_IMAGE_ERROR) { success = false; failure_reason = "copie d'une image impossible"; break; } - if (copied == 0) { + if (copied == COPY_IMAGE_COLLISION) { ++result->already_present; struct stat destination_info; bool destination_is_regular = false; @@ -746,6 +871,8 @@ lardon3d_import_directory( destination_is_regular = true; } if (!destination_is_regular) { + ++processed; + publish_progress(control, result, processed, "Import en cours..."); continue; } } else { @@ -773,8 +900,21 @@ lardon3d_import_directory( failure_reason = "écriture du manifeste impossible"; break; } + ++processed; + publish_progress(control, result, processed, "Import en cours..."); } + if (cancelled) { + return cancel_import( + state, + result, + processed, + &manifest, + &candidates, + originals_path, + control + ); + } if (!success) { manifest_abort(&manifest); size_t removed = rollback_created_files( @@ -782,7 +922,20 @@ lardon3d_import_directory( originals_path ); free(candidates.items); - return fail_import(state, result, removed, failure_reason); + (void)fail_import(state, result, removed, failure_reason); + publish_progress(control, result, processed, state->status_message); + return LARDON3D_IMPORT_FAILED; + } + if (import_is_cancelled(control)) { + return cancel_import( + state, + result, + processed, + &manifest, + &candidates, + originals_path, + control + ); } if (!manifest_commit(state, &manifest)) { size_t removed = rollback_created_files( @@ -790,12 +943,14 @@ lardon3d_import_directory( originals_path ); free(candidates.items); - return fail_import( + (void)fail_import( state, result, removed, "mise à jour du manifeste impossible" ); + publish_progress(control, result, processed, state->status_message); + return LARDON3D_IMPORT_FAILED; } free(candidates.items); @@ -808,5 +963,21 @@ lardon3d_import_directory( result->already_present, result->already_present == 1 ? "" : "s" ); - return true; + publish_progress(control, result, processed, "Import terminé."); + return LARDON3D_IMPORT_SUCCEEDED; +} + +bool +lardon3d_import_directory( + Lardon3DAppState *state, + const char *source_directory, + Lardon3DImportResult *result +) +{ + return lardon3d_import_directory_controlled( + state, + source_directory, + result, + NULL + ) == LARDON3D_IMPORT_SUCCEEDED; } diff --git a/src/import_task.c b/src/import_task.c new file mode 100644 index 0000000..2979c2f --- /dev/null +++ b/src/import_task.c @@ -0,0 +1,229 @@ +#include +#include +#include +#include +#include + +#include +#include + +struct Lardon3DImportTask { + pthread_mutex_t mutex; + pthread_t thread; + bool thread_started; + bool joined; + bool cancel_requested; + Lardon3DAppState worker_state; + char source_directory[PATH_MAX]; + Lardon3DImportTaskSnapshot snapshot; +}; + +static void +copy_message(char destination[256], const char *message) +{ + (void)snprintf(destination, 256, "%s", message ? message : ""); +} + +static bool +task_is_cancelled(void *context) +{ + Lardon3DImportTask *task = context; + bool cancelled; + (void)pthread_mutex_lock(&task->mutex); + cancelled = task->cancel_requested; + (void)pthread_mutex_unlock(&task->mutex); + return cancelled; +} + +static void +task_progressed(void *context, const Lardon3DImportProgress *progress) +{ + Lardon3DImportTask *task = context; + (void)pthread_mutex_lock(&task->mutex); + task->snapshot.total = progress->total; + task->snapshot.processed = progress->processed; + task->snapshot.copied = progress->copied; + task->snapshot.already_present = progress->already_present; + task->snapshot.ignored = progress->ignored; + copy_message(task->snapshot.message, progress->message); + (void)pthread_mutex_unlock(&task->mutex); +} + +static void * +run_import(void *argument) +{ + Lardon3DImportTask *task = argument; + Lardon3DImportResult result; + const Lardon3DImportControl control = { + .context = task, + .is_cancelled = task_is_cancelled, + .progressed = task_progressed, + }; + Lardon3DImportOutcome outcome = lardon3d_import_directory_controlled( + &task->worker_state, + task->source_directory, + &result, + &control + ); + + (void)pthread_mutex_lock(&task->mutex); + task->snapshot.status = outcome == LARDON3D_IMPORT_SUCCEEDED + ? LARDON3D_IMPORT_TASK_SUCCEEDED + : outcome == LARDON3D_IMPORT_CANCELLED + ? LARDON3D_IMPORT_TASK_CANCELLED + : LARDON3D_IMPORT_TASK_FAILED; + task->snapshot.total = result.admissible_found; + task->snapshot.copied = result.copied; + task->snapshot.already_present = result.already_present; + copy_message(task->snapshot.message, task->worker_state.status_message); + (void)pthread_mutex_unlock(&task->mutex); + return NULL; +} + +Lardon3DImportTask * +lardon3d_import_task_create(void) +{ + Lardon3DImportTask *task = calloc(1, sizeof(*task)); + if (!task) { + return NULL; + } + if (pthread_mutex_init(&task->mutex, NULL) != 0) { + free(task); + return NULL; + } + task->snapshot.status = LARDON3D_IMPORT_TASK_IDLE; + return task; +} + +bool +lardon3d_import_task_start( + Lardon3DImportTask *task, + const Lardon3DAppState *state, + const char *source_directory +) +{ + if (!task || !state || !source_directory) { + return false; + } + + (void)pthread_mutex_lock(&task->mutex); + if (task->thread_started) { + (void)pthread_mutex_unlock(&task->mutex); + return false; + } + int written = snprintf( + task->source_directory, + sizeof(task->source_directory), + "%s", + source_directory + ); + if (written < 0 || (size_t)written >= sizeof(task->source_directory)) { + copy_message(task->snapshot.message, "Erreur : chemin source trop long."); + task->snapshot.status = LARDON3D_IMPORT_TASK_FAILED; + (void)pthread_mutex_unlock(&task->mutex); + return false; + } + task->worker_state = *state; + task->cancel_requested = false; + task->joined = false; + task->snapshot = (Lardon3DImportTaskSnapshot) { + .status = LARDON3D_IMPORT_TASK_RUNNING, + .message = "Analyse du dossier source...", + }; + task->thread_started = true; + int error = pthread_create(&task->thread, NULL, run_import, task); + if (error != 0) { + task->thread_started = false; + task->snapshot.status = LARDON3D_IMPORT_TASK_FAILED; + copy_message( + task->snapshot.message, + "Erreur : impossible de lancer la tâche d'import." + ); + (void)pthread_mutex_unlock(&task->mutex); + return false; + } + (void)pthread_mutex_unlock(&task->mutex); + return true; +} + +void +lardon3d_import_task_request_cancel(Lardon3DImportTask *task) +{ + if (!task) { + return; + } + (void)pthread_mutex_lock(&task->mutex); + if (task->snapshot.status == LARDON3D_IMPORT_TASK_RUNNING) { + task->cancel_requested = true; + copy_message(task->snapshot.message, "Annulation demandée..."); + } + (void)pthread_mutex_unlock(&task->mutex); +} + +bool +lardon3d_import_task_snapshot( + Lardon3DImportTask *task, + Lardon3DImportTaskSnapshot *snapshot +) +{ + if (!task || !snapshot) { + return false; + } + (void)pthread_mutex_lock(&task->mutex); + *snapshot = task->snapshot; + (void)pthread_mutex_unlock(&task->mutex); + return true; +} + +bool +lardon3d_import_task_is_finished(Lardon3DImportTask *task) +{ + Lardon3DImportTaskSnapshot snapshot; + if (!lardon3d_import_task_snapshot(task, &snapshot)) { + return false; + } + return snapshot.status == LARDON3D_IMPORT_TASK_SUCCEEDED + || snapshot.status == LARDON3D_IMPORT_TASK_CANCELLED + || snapshot.status == LARDON3D_IMPORT_TASK_FAILED; +} + +bool +lardon3d_import_task_join(Lardon3DImportTask *task) +{ + if (!task) { + return false; + } + + (void)pthread_mutex_lock(&task->mutex); + bool started = task->thread_started; + bool joined = task->joined; + bool should_join = started && !joined; + pthread_t thread = task->thread; + (void)pthread_mutex_unlock(&task->mutex); + if (!should_join) { + return started && joined; + } + if (pthread_join(thread, NULL) != 0) { + return false; + } + (void)pthread_mutex_lock(&task->mutex); + task->joined = true; + (void)pthread_mutex_unlock(&task->mutex); + return true; +} + +void +lardon3d_import_task_destroy(Lardon3DImportTask *task) +{ + if (!task) { + return; + } + if (task->thread_started && !task->joined) { + lardon3d_import_task_request_cancel(task); + if (!lardon3d_import_task_join(task)) { + return; + } + } + (void)pthread_mutex_destroy(&task->mutex); + free(task); +} diff --git a/src/layout.c b/src/layout.c index 31748b9..ba5c39b 100644 --- a/src/layout.c +++ b/src/layout.c @@ -1,4 +1,5 @@ #include +#include #include #include @@ -134,9 +135,62 @@ static void draw_import_screen( const char *input_text, const char *input_label, + const Lardon3DImportTaskSnapshot *snapshot, int columns ) { + if (snapshot && snapshot->status == LARDON3D_IMPORT_TASK_RUNNING) { + draw_text(6, 4, columns - 6, "Import en cours"); + char line[128]; + (void)snprintf( + line, + sizeof(line), + "Fichiers : %zu / %zu", + snapshot->processed, + snapshot->total + ); + draw_text(7, 4, columns - 6, line); + (void)snprintf(line, sizeof(line), "Copiés : %zu", snapshot->copied); + draw_text(8, 4, columns - 6, line); + (void)snprintf( + line, + sizeof(line), + "Déjà présents : %zu", + snapshot->already_present + ); + draw_text(9, 4, columns - 6, line); + (void)snprintf(line, sizeof(line), "Ignorés : %zu", snapshot->ignored); + draw_text(10, 4, columns - 6, line); + + int percent = snapshot->total == 0 + ? 0 + : (int)((snapshot->processed * 100) / snapshot->total); + if (percent > 100) { + percent = 100; + } + int bar_width = columns - 18; + if (bar_width > 40) { + bar_width = 40; + } + if (bar_width < 1) { + bar_width = 1; + } + int filled = (bar_width * percent) / 100; + char bar[64]; + bar[0] = '['; + for (int index = 0; index < bar_width; ++index) { + bar[index + 1] = index < filled ? '#' : '-'; + } + (void)snprintf( + bar + bar_width + 1, + sizeof(bar) - (size_t)bar_width - 1, + "] %d %%", + percent + ); + draw_text(11, 4, columns - 6, bar); + draw_text(12, 4, columns - 6, "C : Annuler l'import"); + return; + } draw_text(7, 4, columns - 6, "I : Importer des images"); draw_text(8, 4, columns - 6, "ESC : Accueil"); draw_text(9, 4, columns - 6, "Q : Quitter"); @@ -148,6 +202,7 @@ draw_content( const Lardon3DAppState *state, const char *input_text, const char *input_label, + const Lardon3DImportTaskSnapshot *import_snapshot, int rows, int columns ) @@ -156,6 +211,11 @@ draw_content( const char *content; const char *footer; screen_texts(state->screen, &title, &content, &footer); + bool import_running = import_snapshot + && import_snapshot->status == LARDON3D_IMPORT_TASK_RUNNING; + if (import_running) { + footer = "C Annuler l'import Q désactivé"; + } int title_length = (int)strlen(title); int content_length = (int)strlen(content); int journal_row = rows - 7; @@ -176,7 +236,12 @@ draw_content( columns ); } else if (state->screen == LARDON3D_SCREEN_IMPORT) { - draw_import_screen(input_text, input_label, columns); + draw_import_screen( + input_text, + input_label, + import_snapshot, + columns + ); } else { draw_text( (3 + journal_row) / 2, @@ -186,7 +251,12 @@ draw_content( ); } draw_text(journal_row + 1, 2, columns - 4, "Journal"); - draw_text(journal_row + 2, 4, columns - 6, state->status_message); + draw_text( + journal_row + 2, + 4, + columns - 6, + import_running ? import_snapshot->message : state->status_message + ); draw_text(rows - 2, 2, columns - 4, footer); } @@ -195,6 +265,7 @@ lardon3d_layout_draw( const Lardon3DAppState *state, const char *input_text, const char *input_label, + const Lardon3DImportTaskSnapshot *import_snapshot, int rows, int columns ) @@ -209,6 +280,7 @@ lardon3d_layout_draw( state, input_text, input_label, + import_snapshot, rows, columns ); diff --git a/src/tui.c b/src/tui.c index 2517438..7537885 100644 --- a/src/tui.c +++ b/src/tui.c @@ -4,7 +4,7 @@ #include #include -#include +#include #include #include #include @@ -28,7 +28,11 @@ typedef struct { } TuiInput; static void -redraw(const Lardon3DAppState *state, const TuiInput *input) +redraw( + const Lardon3DAppState *state, + const TuiInput *input, + Lardon3DImportTask *task +) { int rows; int columns; @@ -41,7 +45,19 @@ redraw(const Lardon3DAppState *state, const TuiInput *input) } else if (input->mode == INPUT_IMPORT_DIRECTORY) { label = "Dossier source :"; } - lardon3d_layout_draw(state, text, label, rows, columns); + Lardon3DImportTaskSnapshot snapshot; + const Lardon3DImportTaskSnapshot *displayed_snapshot = NULL; + if (task && lardon3d_import_task_snapshot(task, &snapshot)) { + displayed_snapshot = &snapshot; + } + lardon3d_layout_draw( + state, + text, + label, + displayed_snapshot, + rows, + columns + ); (void)curs_set( input->mode != INPUT_NONE && rows >= MINIMUM_ROWS @@ -51,10 +67,48 @@ redraw(const Lardon3DAppState *state, const TuiInput *input) ); } +static bool +start_import_task( + Lardon3DAppState *state, + TuiInput *input, + Lardon3DImportTask **task +) +{ + *task = lardon3d_import_task_create(); + if (!*task) { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Erreur : impossible de créer la tâche d'import." + ); + input->mode = INPUT_NONE; + return false; + } + if (!lardon3d_import_task_start(*task, state, input->text)) { + Lardon3DImportTaskSnapshot snapshot; + if (lardon3d_import_task_snapshot(*task, &snapshot) + && snapshot.message[0]) { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "%s", + snapshot.message + ); + } + lardon3d_import_task_destroy(*task); + *task = NULL; + input->mode = INPUT_NONE; + return false; + } + input->mode = INPUT_NONE; + return true; +} + static bool handle_active_input( Lardon3DAppState *state, TuiInput *input, + Lardon3DImportTask **task, int key ) { @@ -83,8 +137,7 @@ handle_active_input( if (input->mode == INPUT_PROJECT_OPEN) { (void)lardon3d_project_open(state, input->text); } else if (input->mode == INPUT_IMPORT_DIRECTORY) { - Lardon3DImportResult result; - (void)lardon3d_import_directory(state, input->text, &result); + (void)start_import_task(state, input, task); } else { (void)lardon3d_project_create(state, input->text); } @@ -105,8 +158,11 @@ handle_active_input( if (input->mode == INPUT_PROJECT_OPEN) { (void)lardon3d_project_open(state, input->text); } else if (input->mode == INPUT_IMPORT_DIRECTORY) { - Lardon3DImportResult result; - (void)lardon3d_import_directory(state, input->text, &result); + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Erreur : chemin source trop long." + ); } else { (void)lardon3d_project_create(state, input->text); } @@ -127,9 +183,29 @@ static bool handle_normal_input( Lardon3DAppState *state, TuiInput *input, + Lardon3DImportTask *task, int key ) { + if (task) { + if (key == 'c' || key == 'C') { + lardon3d_import_task_request_cancel(task); + } else if (key == 'q' || key == 'Q') { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Quitter est désactivé pendant l'import." + ); + } else if (key == 27) { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Accueil indisponible pendant l'import." + ); + } + return key == KEY_RESIZE || key == 'c' || key == 'C' + || key == 'q' || key == 'Q' || key == 27; + } if (key == 'q' || key == 'Q') { state->running = false; return false; @@ -218,6 +294,7 @@ lardon3d_tui_init(void) endwin(); return false; } + timeout(75); return true; } @@ -230,23 +307,52 @@ lardon3d_tui_run(Lardon3DAppState *state) } TuiInput input = {0}; - redraw(state, &input); + Lardon3DImportTask *task = NULL; + redraw(state, &input, task); while (state->running) { int key = getch(); - if (key == ERR) { - return false; + + bool should_redraw = task != NULL; + if (task && lardon3d_import_task_is_finished(task)) { + Lardon3DImportTaskSnapshot snapshot; + if (!lardon3d_import_task_join(task) + || !lardon3d_import_task_snapshot(task, &snapshot)) { + lardon3d_import_task_destroy(task); + return false; + } + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "%s", + snapshot.message + ); + lardon3d_import_task_destroy(task); + task = NULL; + should_redraw = true; } - bool should_redraw = input.mode != INPUT_NONE - ? handle_active_input(state, &input, key) - : handle_normal_input(state, &input, key); + if (key != ERR) { + should_redraw = (input.mode != INPUT_NONE + ? handle_active_input(state, &input, &task, key) + : handle_normal_input(state, &input, task, key)) + || should_redraw; + } if (should_redraw) { - redraw(state, &input); + redraw(state, &input, task); } } + if (task) { + lardon3d_import_task_request_cancel(task); + if (!lardon3d_import_task_join(task)) { + lardon3d_import_task_destroy(task); + return false; + } + lardon3d_import_task_destroy(task); + } + return true; } diff --git a/tests/test_import_task.c b/tests/test_import_task.c new file mode 100644 index 0000000..7b4734a --- /dev/null +++ b/tests/test_import_task.c @@ -0,0 +1,324 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf(stderr, "Échec ligne %d : %s\n", __LINE__, #condition); \ + return false; \ + } \ + } while (0) + +static bool +join_path(char destination[PATH_MAX], const char *parent, const char *child) +{ + int written = snprintf(destination, PATH_MAX, "%s/%s", parent, child); + return written >= 0 && (size_t)written < PATH_MAX; +} + +static bool +write_all(int descriptor, const void *data, size_t size) +{ + const char *bytes = data; + size_t total = 0; + while (total < size) { + ssize_t written = write(descriptor, bytes + total, size - total); + if (written < 0 && errno == EINTR) { + continue; + } + if (written <= 0) { + return false; + } + total += (size_t)written; + } + return true; +} + +static bool +create_file(const char *path, size_t size) +{ + int descriptor = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); + if (descriptor < 0) { + return false; + } + char block[64 * 1024]; + (void)memset(block, 'L', sizeof(block)); + bool success = true; + while (size > 0) { + size_t chunk = size < sizeof(block) ? size : sizeof(block); + if (!write_all(descriptor, block, chunk)) { + success = false; + break; + } + size -= chunk; + } + if (close(descriptor) != 0) { + success = false; + } + return success; +} + +static bool +read_file(const char *path, char *content, size_t capacity) +{ + int descriptor = open(path, O_RDONLY); + if (descriptor < 0 || capacity == 0) { + return false; + } + size_t total = 0; + while (total + 1 < capacity) { + ssize_t count = read(descriptor, content + total, capacity - total - 1); + if (count == 0) { + break; + } + if (count < 0 && errno == EINTR) { + continue; + } + if (count < 0) { + (void)close(descriptor); + return false; + } + total += (size_t)count; + } + content[total] = '\0'; + return close(descriptor) == 0; +} + +static bool +remove_tree(const char *path) +{ + struct stat info; + if (lstat(path, &info) != 0) { + return errno == ENOENT; + } + if (!S_ISDIR(info.st_mode)) { + return unlink(path) == 0; + } + + DIR *directory = opendir(path); + if (!directory) { + return false; + } + bool success = true; + for (struct dirent *entry = readdir(directory); + entry; + entry = readdir(directory)) { + if (strcmp(entry->d_name, ".") == 0 + || strcmp(entry->d_name, "..") == 0) { + continue; + } + char child[PATH_MAX]; + if (!join_path(child, path, entry->d_name) || !remove_tree(child)) { + success = false; + } + } + if (closedir(directory) != 0 || rmdir(path) != 0) { + success = false; + } + return success; +} + +static bool +has_temporary_file(const char *images_path) +{ + DIR *directory = opendir(images_path); + if (!directory) { + return true; + } + bool found = false; + for (struct dirent *entry = readdir(directory); + entry; + entry = readdir(directory)) { + if (strncmp(entry->d_name, ".manifest.tsv.tmp.", 18) == 0) { + found = true; + } + } + if (closedir(directory) != 0) { + found = true; + } + return found; +} + +static bool +wait_until_finished( + Lardon3DImportTask *task, + Lardon3DImportTaskSnapshot *snapshot +) +{ + const struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; + for (size_t attempt = 0; attempt < 10000; ++attempt) { + if (!lardon3d_import_task_snapshot(task, snapshot) + || snapshot->processed > snapshot->total) { + return false; + } + if (snapshot->status != LARDON3D_IMPORT_TASK_RUNNING) { + return true; + } + (void)nanosleep(&pause, NULL); + } + return false; +} + +static bool +run_success_tests( + Lardon3DAppState *state, + const char *source, + const char *manifest +) +{ + Lardon3DImportTask *unused = lardon3d_import_task_create(); + CHECK(unused); + CHECK(!lardon3d_import_task_start(NULL, state, source)); + CHECK(!lardon3d_import_task_start(unused, NULL, source)); + CHECK(!lardon3d_import_task_start(unused, state, NULL)); + lardon3d_import_task_destroy(unused); + + Lardon3DImportTask *task = lardon3d_import_task_create(); + CHECK(task); + CHECK(lardon3d_import_task_start(task, state, source)); + CHECK(!lardon3d_import_task_start(task, state, source)); + Lardon3DImportTaskSnapshot snapshot; + CHECK(wait_until_finished(task, &snapshot)); + CHECK(snapshot.status == LARDON3D_IMPORT_TASK_SUCCEEDED); + CHECK(snapshot.total == 2); + CHECK(snapshot.processed == 2); + CHECK(snapshot.copied == 2); + CHECK(snapshot.already_present == 0); + CHECK(lardon3d_import_task_join(task)); + CHECK(lardon3d_import_task_join(task)); + lardon3d_import_task_destroy(task); + CHECK(access(manifest, F_OK) == 0); + + task = lardon3d_import_task_create(); + CHECK(task && lardon3d_import_task_start(task, state, source)); + CHECK(wait_until_finished(task, &snapshot)); + CHECK(snapshot.status == LARDON3D_IMPORT_TASK_SUCCEEDED); + CHECK(snapshot.copied == 0); + CHECK(snapshot.already_present == 2); + CHECK(lardon3d_import_task_join(task)); + lardon3d_import_task_destroy(task); + return true; +} + +static bool +run_cancellation_test( + Lardon3DAppState *state, + const char *source, + const char *images, + const char *originals, + const char *manifest +) +{ + char manifest_before[8192]; + CHECK(read_file(manifest, manifest_before, sizeof(manifest_before))); + + Lardon3DImportTask *task = lardon3d_import_task_create(); + CHECK(task && lardon3d_import_task_start(task, state, source)); + Lardon3DImportTaskSnapshot snapshot; + const struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; + bool copy_observed = false; + for (size_t attempt = 0; attempt < 10000; ++attempt) { + CHECK(lardon3d_import_task_snapshot(task, &snapshot)); + CHECK(snapshot.processed <= snapshot.total); + if (snapshot.status != LARDON3D_IMPORT_TASK_RUNNING) { + break; + } + if (snapshot.copied > 0) { + copy_observed = true; + break; + } + (void)nanosleep(&pause, NULL); + } + CHECK(copy_observed); + lardon3d_import_task_request_cancel(task); + CHECK(wait_until_finished(task, &snapshot)); + CHECK(snapshot.status == LARDON3D_IMPORT_TASK_CANCELLED); + CHECK(lardon3d_import_task_join(task)); + lardon3d_import_task_destroy(task); + + char manifest_after[8192]; + CHECK(read_file(manifest, manifest_after, sizeof(manifest_after))); + CHECK(strcmp(manifest_before, manifest_after) == 0); + CHECK(!has_temporary_file(images)); + for (size_t index = 0; index < 8; ++index) { + char filename[64]; + CHECK(snprintf(filename, sizeof(filename), "large-%zu.jpg", index) > 0); + char destination[PATH_MAX]; + CHECK(join_path(destination, originals, filename)); + CHECK(access(destination, F_OK) != 0); + } + return true; +} + +static bool +run_test(void) +{ + char base[] = "/tmp/lardon3d-import-task-test.XXXXXX"; + CHECK(mkdtemp(base)); + char project[PATH_MAX]; + char images[PATH_MAX]; + char originals[PATH_MAX]; + char source[PATH_MAX]; + char large_source[PATH_MAX]; + char manifest[PATH_MAX]; + CHECK(join_path(project, base, "project")); + CHECK(join_path(images, project, "images")); + CHECK(join_path(originals, images, "originals")); + CHECK(join_path(source, base, "source")); + CHECK(join_path(large_source, base, "large-source")); + CHECK(join_path(manifest, images, "manifest.tsv")); + CHECK(mkdir(project, 0755) == 0); + CHECK(mkdir(images, 0755) == 0); + CHECK(mkdir(source, 0755) == 0); + CHECK(mkdir(large_source, 0755) == 0); + + char path[PATH_MAX]; + CHECK(join_path(path, source, "one.jpg")); + CHECK(create_file(path, 3)); + CHECK(join_path(path, source, "two.png")); + CHECK(create_file(path, 7)); + for (size_t index = 0; index < 8; ++index) { + char filename[64]; + CHECK(snprintf(filename, sizeof(filename), "large-%zu.jpg", index) > 0); + CHECK(join_path(path, large_source, filename)); + CHECK(create_file(path, 16 * 1024 * 1024)); + } + + Lardon3DAppState state; + lardon3d_app_state_init(&state); + state.project_loaded = true; + CHECK(snprintf( + state.project_path, + sizeof(state.project_path), + "%s", + project + ) > 0); + CHECK(run_success_tests(&state, source, manifest)); + CHECK(run_cancellation_test( + &state, + large_source, + images, + originals, + manifest + )); + CHECK(remove_tree(base)); + return true; +} + +int +main(void) +{ + return run_test() ? EXIT_SUCCESS : EXIT_FAILURE; +}