From f2efe418cb78bc7c04da525bf76a15312a28d397 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 6 Aug 2026 18:34:24 +0200 Subject: [PATCH] feat: add safe image import with manifest --- README.md | 4 + include/lardon3d/import.h | 22 ++ include/lardon3d/layout.h | 4 +- meson.build | 14 + src/import.c | 812 ++++++++++++++++++++++++++++++++++++++ src/layout.c | 80 ++-- src/project.c | 2 - src/tui.c | 98 +++-- tests/test_import.c | 291 ++++++++++++++ 9 files changed, 1264 insertions(+), 63 deletions(-) create mode 100644 include/lardon3d/import.h create mode 100644 src/import.c create mode 100644 tests/test_import.c diff --git a/README.md b/README.md index 01035aa..713e879 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,7 @@ La TUI s'affiche directement dans le terminal courant. Appuyez sur `q` ou `Q` po Les projets sont enregistrés par défaut dans `~/Documents/Lardon/Projets3D`. La variable `LARDON3D_PROJECTS_ROOT` permet de choisir un autre répertoire racine à l'exécution. + +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`. diff --git a/include/lardon3d/import.h b/include/lardon3d/import.h new file mode 100644 index 0000000..6983075 --- /dev/null +++ b/include/lardon3d/import.h @@ -0,0 +1,22 @@ +#ifndef LARDON3D_IMPORT_H +#define LARDON3D_IMPORT_H + +#include +#include + +#include + +typedef struct { + size_t admissible_found; + size_t copied; + size_t already_present; + size_t ignored; +} Lardon3DImportResult; + +bool lardon3d_import_directory( + Lardon3DAppState *state, + const char *source_directory, + Lardon3DImportResult *result +); + +#endif diff --git a/include/lardon3d/layout.h b/include/lardon3d/layout.h index 8accd12..c9a75f3 100644 --- a/include/lardon3d/layout.h +++ b/include/lardon3d/layout.h @@ -5,8 +5,8 @@ void lardon3d_layout_draw( const Lardon3DAppState *state, - const char *project_name_input, - const char *project_input_label, + const char *input_text, + const char *input_label, int rows, int cols ); diff --git a/meson.build b/meson.build index f9a80c4..1c592a4 100644 --- a/meson.build +++ b/meson.build @@ -11,6 +11,7 @@ project( ) add_project_arguments( + '-D_POSIX_C_SOURCE=200809L', '-Wpedantic', '-Wconversion', '-Wshadow', @@ -27,8 +28,21 @@ executable( 'src/app_state.c', 'src/tui.c', 'src/layout.c', + 'src/import.c', 'src/project.c', ], include_directories: include_directories('include'), dependencies: [ncursesw], ) + +import_test = executable( + 'test-import', + sources: [ + 'tests/test_import.c', + 'src/app_state.c', + 'src/import.c', + ], + include_directories: include_directories('include'), +) + +test('import', import_test) diff --git a/src/import.c b/src/import.c new file mode 100644 index 0000000..bf7b914 --- /dev/null +++ b/src/import.c @@ -0,0 +1,812 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +enum { + MANIFEST_LINE_CAPACITY = PATH_MAX + 512, +}; + +typedef struct { + FILE *file; + char temporary_path[PATH_MAX]; + char final_path[PATH_MAX]; + bool previous_exists; +} ManifestWriter; + +typedef struct { + char filename[NAME_MAX + 1]; + bool created; +} ImportCandidate; + +typedef struct { + ImportCandidate *items; + size_t count; + size_t capacity; +} CandidateList; + +static void +set_status(Lardon3DAppState *state, const char *message) +{ + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "%s", + message + ); +} + +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 +trim_source_path( + Lardon3DAppState *state, + const char *input, + char output[PATH_MAX] +) +{ + if (!input) { + set_status(state, "Erreur : dossier source vide."); + return false; + } + + const char *start = input; + while (*start && isspace((unsigned char)*start)) { + ++start; + } + const char *end = input + strlen(input); + while (end > start && isspace((unsigned char)end[-1])) { + --end; + } + + size_t length = (size_t)(end - start); + if (length == 0) { + set_status(state, "Erreur : dossier source vide."); + return false; + } + if (length >= PATH_MAX) { + set_status(state, "Erreur : chemin source trop long."); + return false; + } + (void)memcpy(output, start, length); + output[length] = '\0'; + return true; +} + +static bool +resolve_source_path( + Lardon3DAppState *state, + const char *source, + char absolute_source[PATH_MAX] +) +{ + if (source[0] == '/') { + int written = snprintf(absolute_source, PATH_MAX, "%s", source); + if (written >= 0 && (size_t)written < PATH_MAX) { + return true; + } + } else { + char current_directory[PATH_MAX]; + if (getcwd(current_directory, sizeof(current_directory)) + && join_path(absolute_source, current_directory, source)) { + return true; + } + } + + set_status(state, "Erreur : chemin source trop long ou inaccessible."); + return false; +} + +static bool +has_supported_extension(const char *filename) +{ + const char *extension = strrchr(filename, '.'); + if (!extension) { + return false; + } + + return strcasecmp(extension, ".jpg") == 0 + || strcasecmp(extension, ".jpeg") == 0 + || strcasecmp(extension, ".png") == 0 + || strcasecmp(extension, ".tif") == 0 + || strcasecmp(extension, ".tiff") == 0 + || strcasecmp(extension, ".heic") == 0; +} + +static bool +has_forbidden_manifest_character(const char *text) +{ + return strchr(text, '\t') || strchr(text, '\n') || strchr(text, '\r'); +} + +static bool +candidate_list_append( + Lardon3DAppState *state, + CandidateList *candidates, + const char *filename +) +{ + if (candidates->count == candidates->capacity) { + size_t capacity = candidates->capacity == 0 + ? 16 + : candidates->capacity * 2; + if (capacity < candidates->capacity + || capacity > SIZE_MAX / sizeof(*candidates->items)) { + set_status(state, "Erreur : trop de fichiers à importer."); + return false; + } + void *items = realloc( + candidates->items, + capacity * sizeof(*candidates->items) + ); + if (!items) { + set_status(state, "Erreur : mémoire insuffisante pour l'import."); + return false; + } + candidates->items = items; + candidates->capacity = capacity; + } + + ImportCandidate *candidate = &candidates->items[candidates->count]; + int written = snprintf( + candidate->filename, + sizeof(candidate->filename), + "%s", + filename + ); + if (written < 0 || (size_t)written >= sizeof(candidate->filename)) { + set_status(state, "Erreur : nom de fichier trop long."); + return false; + } + candidate->created = false; + ++candidates->count; + return true; +} + +static bool +ensure_originals_directory( + Lardon3DAppState *state, + char images_path[PATH_MAX], + char originals_path[PATH_MAX] +) +{ + 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; + } + + struct stat info; + if (lstat(images_path, &info) != 0 || !S_ISDIR(info.st_mode)) { + set_status(state, "Erreur : dossier images absent ou invalide."); + return false; + } + if (lstat(originals_path, &info) == 0) { + if (!S_ISDIR(info.st_mode)) { + set_status(state, "Erreur : images/originals n'est pas un dossier."); + return false; + } + return true; + } + if (errno != ENOENT || mkdir(originals_path, 0755) != 0) { + set_status(state, "Erreur : impossible de créer images/originals."); + return false; + } + return true; +} + +static bool +manifest_line_is_valid(const char *line) +{ + const char *first_tab = strchr(line, '\t'); + if (!first_tab || first_tab == line) { + return false; + } + const char *second_tab = strchr(first_tab + 1, '\t'); + if (!second_tab || second_tab == first_tab + 1) { + return false; + } + if (strchr(second_tab + 1, '\t') || !strchr(second_tab + 1, '\n')) { + return false; + } + for (const char *digit = first_tab + 1; digit < second_tab; ++digit) { + if (!isdigit((unsigned char)*digit)) { + return false; + } + } + return true; +} + +static void +manifest_abort(ManifestWriter *writer) +{ + if (writer->file) { + (void)fclose(writer->file); + writer->file = NULL; + } + if (writer->temporary_path[0]) { + (void)unlink(writer->temporary_path); + } +} + +static bool +manifest_begin( + Lardon3DAppState *state, + const char *images_path, + ManifestWriter *writer +) +{ + *writer = (ManifestWriter) {0}; + if (!join_path(writer->final_path, images_path, "manifest.tsv") + || !join_path( + writer->temporary_path, + images_path, + ".manifest.tsv.tmp.XXXXXX" + )) { + set_status(state, "Erreur : chemin du manifeste trop long."); + return false; + } + + FILE *previous = NULL; + int previous_descriptor = open( + writer->final_path, + O_RDONLY | O_NOFOLLOW + ); + if (previous_descriptor >= 0) { + struct stat info; + if (fstat(previous_descriptor, &info) != 0 || !S_ISREG(info.st_mode)) { + (void)close(previous_descriptor); + set_status(state, "Erreur : manifest.tsv invalide."); + return false; + } + previous = fdopen(previous_descriptor, "r"); + if (!previous) { + (void)close(previous_descriptor); + set_status(state, "Erreur : impossible de lire manifest.tsv."); + return false; + } + writer->previous_exists = true; + } else if (errno != ENOENT) { + set_status(state, "Erreur : impossible de lire manifest.tsv."); + return false; + } + + int descriptor = mkstemp(writer->temporary_path); + if (descriptor < 0) { + if (previous) { + (void)fclose(previous); + } + set_status(state, "Erreur : impossible de préparer manifest.tsv."); + return false; + } + writer->file = fdopen(descriptor, "w"); + if (!writer->file) { + (void)close(descriptor); + if (previous) { + (void)fclose(previous); + } + manifest_abort(writer); + set_status(state, "Erreur : impossible d'écrire manifest.tsv."); + return false; + } + + bool success = fputs( + "filename\tsize_bytes\tsource_path\n", + writer->file + ) >= 0; + if (previous) { + char line[MANIFEST_LINE_CAPACITY]; + if (!fgets(line, sizeof(line), previous) + || strcmp(line, "filename\tsize_bytes\tsource_path\n") != 0) { + success = false; + } + while (success && fgets(line, sizeof(line), previous)) { + if (!manifest_line_is_valid(line) + || fputs(line, writer->file) < 0) { + success = false; + } + } + if (ferror(previous) || fclose(previous) != 0) { + success = false; + } + } + + if (!success) { + manifest_abort(writer); + set_status(state, "Erreur : manifest.tsv invalide ou illisible."); + } + return success; +} + +static int +manifest_contains(const ManifestWriter *writer, const char *filename) +{ + if (!writer->previous_exists) { + return 0; + } + + int descriptor = open(writer->final_path, O_RDONLY | O_NOFOLLOW); + if (descriptor < 0) { + return -1; + } + FILE *file = fdopen(descriptor, "r"); + if (!file) { + (void)close(descriptor); + return -1; + } + + int found = 0; + char line[MANIFEST_LINE_CAPACITY]; + (void)fgets(line, sizeof(line), file); + size_t filename_length = strlen(filename); + while (fgets(line, sizeof(line), file)) { + const char *tab = strchr(line, '\t'); + if (tab && (size_t)(tab - line) == filename_length + && memcmp(line, filename, filename_length) == 0) { + found = 1; + break; + } + } + if (ferror(file) || fclose(file) != 0) { + return -1; + } + return found; +} + +static bool +manifest_append( + ManifestWriter *writer, + const char *filename, + off_t size, + const char *source_path +) +{ + return fprintf( + writer->file, + "%s\t%" PRIdMAX "\t%s\n", + filename, + (intmax_t)size, + source_path + ) >= 0; +} + +static bool +manifest_commit(Lardon3DAppState *state, ManifestWriter *writer) +{ + bool success = fflush(writer->file) == 0; + if (success) { + success = fsync(fileno(writer->file)) == 0; + } + if (fclose(writer->file) != 0) { + success = false; + } + writer->file = NULL; + if (success) { + success = rename(writer->temporary_path, writer->final_path) == 0; + } + if (!success) { + (void)unlink(writer->temporary_path); + set_status(state, "Erreur : impossible de mettre à jour manifest.tsv."); + } + return success; +} + +static int +copy_image( + const char *source_path, + const char *destination_path, + off_t *destination_size +) +{ + int source = open(source_path, O_RDONLY | O_NOFOLLOW); + if (source < 0) { + return -1; + } + + struct stat source_info; + if (fstat(source, &source_info) != 0 || !S_ISREG(source_info.st_mode)) { + (void)close(source); + return -1; + } + + mode_t mode = source_info.st_mode & 0666; + if (mode == 0) { + mode = 0644; + } + int destination = open( + destination_path, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, + mode + ); + if (destination < 0) { + (void)close(source); + return errno == EEXIST ? 0 : -1; + } + + bool success = true; + char buffer[64 * 1024]; + for (;;) { + ssize_t read_count = read(source, buffer, sizeof(buffer)); + if (read_count == 0) { + break; + } + if (read_count < 0) { + if (errno == EINTR) { + continue; + } + success = false; + break; + } + + ssize_t written_total = 0; + while (written_total < read_count) { + ssize_t written = write( + destination, + buffer + written_total, + (size_t)(read_count - written_total) + ); + if (written < 0 && errno == EINTR) { + continue; + } + if (written <= 0) { + success = false; + break; + } + written_total += written; + } + if (!success) { + break; + } + } + + if (success) { + success = fsync(destination) == 0; + } + if (close(source) != 0) { + success = false; + } + if (close(destination) != 0) { + success = false; + } + if (!success) { + (void)unlink(destination_path); + return -1; + } + + *destination_size = source_info.st_size; + return 1; +} + +static bool +analyze_source_directory( + Lardon3DAppState *state, + const char *absolute_source, + const char *originals_path, + Lardon3DImportResult *result, + CandidateList *candidates +) +{ + DIR *directory = opendir(absolute_source); + if (!directory) { + set_status(state, "Erreur : impossible d'ouvrir le dossier source."); + return false; + } + + bool success = true; + for (;;) { + errno = 0; + struct dirent *entry = readdir(directory); + if (!entry) { + if (errno != 0) { + set_status(state, "Erreur : lecture du dossier source impossible."); + success = false; + } + break; + } + if (strcmp(entry->d_name, ".") == 0 + || strcmp(entry->d_name, "..") == 0) { + continue; + } + + char source_path[PATH_MAX]; + if (!join_path(source_path, absolute_source, entry->d_name)) { + set_status(state, "Erreur : chemin source trop long."); + success = false; + break; + } + + struct stat source_info; + if (lstat(source_path, &source_info) != 0 + || !S_ISREG(source_info.st_mode) + || !has_supported_extension(entry->d_name)) { + ++result->ignored; + continue; + } + ++result->admissible_found; + + if (has_forbidden_manifest_character(entry->d_name)) { + set_status( + state, + "Erreur : nom de fichier incompatible avec le manifeste." + ); + success = false; + break; + } + + char destination_path[PATH_MAX]; + if (!join_path(destination_path, originals_path, entry->d_name)) { + set_status(state, "Erreur : chemin destination trop long."); + success = false; + break; + } + if (!candidate_list_append(state, candidates, entry->d_name)) { + success = false; + break; + } + } + + if (closedir(directory) != 0) { + set_status(state, "Erreur : fermeture du dossier source impossible."); + success = false; + } + return success; +} + +static size_t +rollback_created_files( + CandidateList *candidates, + const char *originals_path +) +{ + size_t removed = 0; + for (size_t index = candidates->count; index > 0; --index) { + ImportCandidate *candidate = &candidates->items[index - 1]; + if (!candidate->created) { + continue; + } + + char destination_path[PATH_MAX]; + if (join_path( + destination_path, + originals_path, + candidate->filename + ) + && unlink(destination_path) == 0) { + candidate->created = false; + ++removed; + } + } + return removed; +} + +static bool +fail_import( + Lardon3DAppState *state, + Lardon3DImportResult *result, + size_t removed, + const char *reason +) +{ + size_t copied_before_rollback = result->copied; + result->copied -= removed; + if (copied_before_rollback > 0 && result->copied == 0) { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Import annulé : %zu copie%s retirée%s après erreur (%s).", + removed, + removed == 1 ? "" : "s", + removed == 1 ? "" : "s", + reason + ); + } else if (result->copied > 0) { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Erreur critique : %zu copie%s conservée%s sans manifeste (%s).", + result->copied, + result->copied == 1 ? "" : "s", + result->copied == 1 ? "" : "s", + reason + ); + } else { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Erreur d'import : %s.", + reason + ); + } + return false; +} + +bool +lardon3d_import_directory( + Lardon3DAppState *state, + const char *source_directory, + Lardon3DImportResult *result +) +{ + if (!state || !result) { + return false; + } + *result = (Lardon3DImportResult) {0}; + + if (!state->project_loaded) { + set_status(state, "Aucun projet chargé."); + return false; + } + + char trimmed_source[PATH_MAX]; + char absolute_source[PATH_MAX]; + if (!trim_source_path(state, source_directory, trimmed_source)) { + return false; + } + if (!resolve_source_path(state, trimmed_source, absolute_source)) { + return false; + } + if (has_forbidden_manifest_character(absolute_source)) { + set_status(state, "Erreur : chemin source incompatible avec le manifeste."); + return false; + } + + struct stat source_directory_info; + if (lstat(absolute_source, &source_directory_info) != 0) { + set_status(state, "Erreur : dossier source inexistant."); + return false; + } + if (!S_ISDIR(source_directory_info.st_mode)) { + set_status(state, "Erreur : la source n'est pas un dossier."); + return false; + } + + char images_path[PATH_MAX]; + char originals_path[PATH_MAX]; + 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; + } + + CandidateList candidates = {0}; + if (!analyze_source_directory( + state, + absolute_source, + originals_path, + result, + &candidates + )) { + free(candidates.items); + return false; + } + + if (!ensure_originals_directory(state, images_path, originals_path)) { + free(candidates.items); + return false; + } + + ManifestWriter manifest; + if (!manifest_begin(state, images_path, &manifest)) { + free(candidates.items); + return false; + } + + bool success = true; + const char *failure_reason = NULL; + for (size_t index = 0; index < candidates.count; ++index) { + ImportCandidate *candidate = &candidates.items[index]; + char source_path[PATH_MAX]; + char destination_path[PATH_MAX]; + if (!join_path(source_path, absolute_source, candidate->filename) + || !join_path( + destination_path, + originals_path, + candidate->filename + )) { + success = false; + failure_reason = "chemin de fichier trop long"; + break; + } + + off_t size = 0; + int copied = copy_image(source_path, destination_path, &size); + if (copied < 0) { + success = false; + failure_reason = "copie d'une image impossible"; + break; + } + if (copied == 0) { + ++result->already_present; + struct stat destination_info; + bool destination_is_regular = false; + if (lstat(destination_path, &destination_info) == 0 + && S_ISREG(destination_info.st_mode)) { + size = destination_info.st_size; + destination_is_regular = true; + } + if (!destination_is_regular) { + continue; + } + } else { + ++result->copied; + candidate->created = true; + } + + int already_listed = manifest_contains( + &manifest, + candidate->filename + ); + if (already_listed < 0) { + success = false; + failure_reason = "lecture du manifeste impossible"; + break; + } + if (!already_listed + && !manifest_append( + &manifest, + candidate->filename, + size, + source_path + )) { + success = false; + failure_reason = "écriture du manifeste impossible"; + break; + } + } + + if (!success) { + manifest_abort(&manifest); + size_t removed = rollback_created_files( + &candidates, + originals_path + ); + free(candidates.items); + return fail_import(state, result, removed, failure_reason); + } + if (!manifest_commit(state, &manifest)) { + size_t removed = rollback_created_files( + &candidates, + originals_path + ); + free(candidates.items); + return fail_import( + state, + result, + removed, + "mise à jour du manifeste impossible" + ); + } + free(candidates.items); + + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Import terminé : %zu copiée%s, %zu déjà présente%s.", + result->copied, + result->copied == 1 ? "" : "s", + result->already_present, + result->already_present == 1 ? "" : "s" + ); + return true; +} diff --git a/src/layout.c b/src/layout.c index de2180c..31748b9 100644 --- a/src/layout.c +++ b/src/layout.c @@ -69,6 +69,7 @@ screen_texts( case LARDON3D_SCREEN_IMPORT: *title = "Import"; *content = "Import des images"; + *footer = "I Importer des images ESC Accueil Q Quit"; break; case LARDON3D_SCREEN_VIEWER: *title = "Viewer"; @@ -87,10 +88,37 @@ screen_texts( } } +static void +draw_input_field( + const char *input_text, + const char *input_label, + int row, + int columns +) +{ + if (!input_text) { + return; + } + + draw_text(row, 4, columns - 6, input_label); + (void)mvaddch(row + 1, 2, '['); + (void)mvaddch(row + 1, columns - 3, ']'); + + int available = columns - 8; + size_t length = strlen(input_text); + const char *visible = input_text; + if (length > (size_t)available) { + visible += length - (size_t)available; + length = (size_t)available; + } + draw_text(row + 1, 4, available, visible); + (void)move(row + 1, 4 + (int)length); +} + static void draw_project_screen( - const char *project_name_input, - const char *project_input_label, + const char *input_text, + const char *input_label, int columns ) { @@ -99,31 +127,27 @@ draw_project_screen( draw_text(8, 4, columns - 6, "C : Fermer le projet"); draw_text(9, 4, columns - 6, "ESC : Accueil"); draw_text(10, 4, columns - 6, "Q : Quitter"); + draw_input_field(input_text, input_label, 11, columns); +} - if (!project_name_input) { - return; - } - - draw_text(11, 4, columns - 6, project_input_label); - (void)mvaddch(12, 2, '['); - (void)mvaddch(12, columns - 3, ']'); - - int available = columns - 8; - size_t length = strlen(project_name_input); - const char *visible = project_name_input; - if (length > (size_t)available) { - visible += length - (size_t)available; - length = (size_t)available; - } - draw_text(12, 4, available, visible); - (void)move(12, 4 + (int)length); +static void +draw_import_screen( + const char *input_text, + const char *input_label, + int columns +) +{ + 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"); + draw_input_field(input_text, input_label, 10, columns); } static void draw_content( const Lardon3DAppState *state, - const char *project_name_input, - const char *project_input_label, + const char *input_text, + const char *input_label, int rows, int columns ) @@ -147,10 +171,12 @@ draw_content( } if (state->screen == LARDON3D_SCREEN_PROJECTS) { draw_project_screen( - project_name_input, - project_input_label, + input_text, + input_label, columns ); + } else if (state->screen == LARDON3D_SCREEN_IMPORT) { + draw_import_screen(input_text, input_label, columns); } else { draw_text( (3 + journal_row) / 2, @@ -167,8 +193,8 @@ draw_content( void lardon3d_layout_draw( const Lardon3DAppState *state, - const char *project_name_input, - const char *project_input_label, + const char *input_text, + const char *input_label, int rows, int columns ) @@ -181,8 +207,8 @@ lardon3d_layout_draw( draw_frame(rows, columns); draw_content( state, - project_name_input, - project_input_label, + input_text, + input_label, rows, columns ); diff --git a/src/project.c b/src/project.c index 94c2bfd..9246252 100644 --- a/src/project.c +++ b/src/project.c @@ -1,5 +1,3 @@ -#define _POSIX_C_SOURCE 200809L - #include #include #include diff --git a/src/tui.c b/src/tui.c index c045e26..2517438 100644 --- a/src/tui.c +++ b/src/tui.c @@ -4,40 +4,46 @@ #include #include +#include #include #include #include enum { - PROJECT_INPUT_CAPACITY = 256, MINIMUM_ROWS = 20, MINIMUM_COLUMNS = 72, }; +typedef enum { + INPUT_NONE = 0, + INPUT_PROJECT_CREATE, + INPUT_PROJECT_OPEN, + INPUT_IMPORT_DIRECTORY, +} InputMode; + typedef struct { - enum { - PROJECT_INPUT_NONE = 0, - PROJECT_INPUT_CREATE, - PROJECT_INPUT_OPEN, - } mode; - char text[PROJECT_INPUT_CAPACITY]; + InputMode mode; + char text[PATH_MAX]; size_t length; -} ProjectInput; +} TuiInput; static void -redraw(const Lardon3DAppState *state, const ProjectInput *input) +redraw(const Lardon3DAppState *state, const TuiInput *input) { int rows; int columns; getmaxyx(stdscr, rows, columns); - const char *text = input->mode != PROJECT_INPUT_NONE ? input->text : NULL; - const char *label = input->mode == PROJECT_INPUT_OPEN - ? "Nom du dossier projet :" - : "Nom du nouveau projet :"; + const char *text = input->mode != INPUT_NONE ? input->text : NULL; + const char *label = "Nom du nouveau projet :"; + if (input->mode == INPUT_PROJECT_OPEN) { + label = "Nom du dossier projet :"; + } else if (input->mode == INPUT_IMPORT_DIRECTORY) { + label = "Dossier source :"; + } lardon3d_layout_draw(state, text, label, rows, columns); (void)curs_set( - input->mode != PROJECT_INPUT_NONE + input->mode != INPUT_NONE && rows >= MINIMUM_ROWS && columns >= MINIMUM_COLUMNS ? 1 @@ -46,9 +52,9 @@ redraw(const Lardon3DAppState *state, const ProjectInput *input) } static bool -handle_project_input( +handle_active_input( Lardon3DAppState *state, - ProjectInput *input, + TuiInput *input, int key ) { @@ -57,10 +63,13 @@ handle_project_input( } if (key == 27) { - const char *message = input->mode == PROJECT_INPUT_OPEN - ? "Ouverture du projet annulée." - : "Création du projet annulée."; - input->mode = PROJECT_INPUT_NONE; + const char *message = "Création du projet annulée."; + if (input->mode == INPUT_PROJECT_OPEN) { + message = "Ouverture du projet annulée."; + } else if (input->mode == INPUT_IMPORT_DIRECTORY) { + message = "Import annulé."; + } + input->mode = INPUT_NONE; (void)snprintf( state->status_message, sizeof(state->status_message), @@ -71,12 +80,15 @@ handle_project_input( } if (key == '\n' || key == '\r' || key == KEY_ENTER) { - if (input->mode == PROJECT_INPUT_OPEN) { + 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); } else { (void)lardon3d_project_create(state, input->text); } - input->mode = PROJECT_INPUT_NONE; + input->mode = INPUT_NONE; return true; } @@ -90,12 +102,15 @@ handle_project_input( if (key >= 0 && key <= UCHAR_MAX && isprint((unsigned char)key)) { if (input->length + 1 >= sizeof(input->text)) { - if (input->mode == PROJECT_INPUT_OPEN) { + 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); } else { (void)lardon3d_project_create(state, input->text); } - input->mode = PROJECT_INPUT_NONE; + input->mode = INPUT_NONE; return true; } @@ -111,7 +126,7 @@ handle_project_input( static bool handle_normal_input( Lardon3DAppState *state, - ProjectInput *input, + TuiInput *input, int key ) { @@ -141,8 +156,8 @@ handle_normal_input( case 'n': case 'N': if (state->screen == LARDON3D_SCREEN_PROJECTS) { - *input = (ProjectInput) { - .mode = PROJECT_INPUT_CREATE, + *input = (TuiInput) { + .mode = INPUT_PROJECT_CREATE, .text = "", .length = 0, }; @@ -152,8 +167,27 @@ handle_normal_input( case 'o': case 'O': if (state->screen == LARDON3D_SCREEN_PROJECTS) { - *input = (ProjectInput) { - .mode = PROJECT_INPUT_OPEN, + *input = (TuiInput) { + .mode = INPUT_PROJECT_OPEN, + .text = "", + .length = 0, + }; + return true; + } + return false; + case 'i': + case 'I': + if (state->screen == LARDON3D_SCREEN_IMPORT) { + if (!state->project_loaded) { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Aucun projet chargé." + ); + return true; + } + *input = (TuiInput) { + .mode = INPUT_IMPORT_DIRECTORY, .text = "", .length = 0, }; @@ -195,7 +229,7 @@ lardon3d_tui_run(Lardon3DAppState *state) return false; } - ProjectInput input = {0}; + TuiInput input = {0}; redraw(state, &input); while (state->running) { @@ -204,8 +238,8 @@ lardon3d_tui_run(Lardon3DAppState *state) return false; } - bool should_redraw = input.mode != PROJECT_INPUT_NONE - ? handle_project_input(state, &input, key) + bool should_redraw = input.mode != INPUT_NONE + ? handle_active_input(state, &input, key) : handle_normal_input(state, &input, key); if (should_redraw) { diff --git a/tests/test_import.c b/tests/test_import.c new file mode 100644 index 0000000..37b44b5 --- /dev/null +++ b/tests/test_import.c @@ -0,0 +1,291 @@ +#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 +create_directory(const char *path) +{ + return mkdir(path, 0755) == 0; +} + +static bool +create_file(const char *path, const char *content) +{ + int descriptor = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); + if (descriptor < 0) { + return false; + } + + size_t length = strlen(content); + size_t written_total = 0; + while (written_total < length) { + ssize_t written = write( + descriptor, + content + written_total, + length - written_total + ); + if (written < 0 && errno == EINTR) { + continue; + } + if (written <= 0) { + (void)close(descriptor); + return false; + } + written_total += (size_t)written; + } + return close(descriptor) == 0; +} + +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 +file_equals(const char *path, const char *expected) +{ + char content[256]; + return read_file(path, content, sizeof(content)) + && strcmp(content, expected) == 0; +} + +static bool +manifest_has_unique_filenames(const char *path, size_t expected_entries) +{ + FILE *file = fopen(path, "r"); + if (!file) { + return false; + } + + char filenames[16][NAME_MAX + 1]; + size_t count = 0; + char line[PATH_MAX + 512]; + bool valid = fgets(line, sizeof(line), file) + && strcmp(line, "filename\tsize_bytes\tsource_path\n") == 0; + while (valid && fgets(line, sizeof(line), file)) { + char *tab = strchr(line, '\t'); + if (!tab || count >= 16) { + valid = false; + break; + } + *tab = '\0'; + for (size_t index = 0; index < count; ++index) { + if (strcmp(filenames[index], line) == 0) { + valid = false; + } + } + int written = snprintf( + filenames[count], + sizeof(filenames[count]), + "%s", + line + ); + if (written < 0 || (size_t)written >= sizeof(filenames[count])) { + valid = false; + } + ++count; + } + if (ferror(file) || fclose(file) != 0) { + valid = false; + } + return valid && count == expected_entries; +} + +static bool +has_manifest_temporary(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 +run_test(void) +{ + char base[] = "/tmp/lardon3d-import-test.XXXXXX"; + CHECK(mkdtemp(base)); + + char project[PATH_MAX]; + char images[PATH_MAX]; + char originals[PATH_MAX]; + char valid_source[PATH_MAX]; + char tab_source[PATH_MAX]; + char newline_source[PATH_MAX]; + CHECK(join_path(project, base, "project")); + CHECK(join_path(images, project, "images")); + CHECK(join_path(originals, images, "originals")); + CHECK(join_path(valid_source, base, "valid")); + CHECK(join_path(tab_source, base, "tab")); + CHECK(join_path(newline_source, base, "newline")); + CHECK(create_directory(project)); + CHECK(create_directory(images)); + CHECK(create_directory(valid_source)); + CHECK(create_directory(tab_source)); + CHECK(create_directory(newline_source)); + + char path[PATH_MAX]; + CHECK(join_path(path, valid_source, "one.jpg")); + CHECK(create_file(path, "one")); + CHECK(join_path(path, valid_source, "two.PNG")); + CHECK(create_file(path, "two-two")); + + Lardon3DAppState state; + lardon3d_app_state_init(&state); + state.project_loaded = true; + CHECK(snprintf( + state.project_path, + sizeof(state.project_path), + "%s", + project + ) > 0); + + Lardon3DImportResult result; + CHECK(lardon3d_import_directory(&state, valid_source, &result)); + CHECK(result.admissible_found == 2); + CHECK(result.copied == 2); + CHECK(result.already_present == 0); + + char manifest[PATH_MAX]; + CHECK(join_path(manifest, images, "manifest.tsv")); + char manifest_before[8192]; + CHECK(read_file(manifest, manifest_before, sizeof(manifest_before))); + + CHECK(join_path(path, tab_source, "would-copy.jpg")); + CHECK(create_file(path, "must-not-copy")); + CHECK(join_path(path, tab_source, "bad\tname.jpg")); + CHECK(create_file(path, "invalid")); + CHECK(!lardon3d_import_directory(&state, tab_source, &result)); + CHECK(join_path(path, originals, "would-copy.jpg")); + CHECK(access(path, F_OK) != 0); + char manifest_after[8192]; + CHECK(read_file(manifest, manifest_after, sizeof(manifest_after))); + CHECK(strcmp(manifest_before, manifest_after) == 0); + CHECK(!has_manifest_temporary(images)); + + CHECK(join_path(path, newline_source, "also-not-copied.jpg")); + CHECK(create_file(path, "must-not-copy-either")); + CHECK(join_path(path, newline_source, "bad\nname.png")); + CHECK(create_file(path, "invalid")); + CHECK(!lardon3d_import_directory(&state, newline_source, &result)); + CHECK(join_path(path, originals, "also-not-copied.jpg")); + CHECK(access(path, F_OK) != 0); + CHECK(read_file(manifest, manifest_after, sizeof(manifest_after))); + CHECK(strcmp(manifest_before, manifest_after) == 0); + CHECK(!has_manifest_temporary(images)); + + CHECK(join_path(path, valid_source, "three.tiff")); + CHECK(create_file(path, "three-three-three")); + CHECK(lardon3d_import_directory(&state, valid_source, &result)); + CHECK(result.copied == 1); + CHECK(result.already_present == 2); + CHECK(lardon3d_import_directory(&state, valid_source, &result)); + CHECK(result.copied == 0); + CHECK(result.already_present == 3); + CHECK(manifest_has_unique_filenames(manifest, 3)); + CHECK(join_path(path, originals, "one.jpg")); + CHECK(file_equals(path, "one")); + CHECK(join_path(path, originals, "two.PNG")); + CHECK(file_equals(path, "two-two")); + CHECK(join_path(path, originals, "three.tiff")); + CHECK(file_equals(path, "three-three-three")); + CHECK(!has_manifest_temporary(images)); + + CHECK(unlink(path) == 0); + CHECK(join_path(path, originals, "two.PNG")); + CHECK(unlink(path) == 0); + CHECK(join_path(path, originals, "one.jpg")); + CHECK(unlink(path) == 0); + CHECK(unlink(manifest) == 0); + CHECK(rmdir(originals) == 0); + CHECK(rmdir(images) == 0); + CHECK(rmdir(project) == 0); + + CHECK(join_path(path, valid_source, "three.tiff")); + CHECK(unlink(path) == 0); + CHECK(join_path(path, valid_source, "two.PNG")); + CHECK(unlink(path) == 0); + CHECK(join_path(path, valid_source, "one.jpg")); + CHECK(unlink(path) == 0); + CHECK(rmdir(valid_source) == 0); + + CHECK(join_path(path, tab_source, "bad\tname.jpg")); + CHECK(unlink(path) == 0); + CHECK(join_path(path, tab_source, "would-copy.jpg")); + CHECK(unlink(path) == 0); + CHECK(rmdir(tab_source) == 0); + + CHECK(join_path(path, newline_source, "bad\nname.png")); + CHECK(unlink(path) == 0); + CHECK(join_path(path, newline_source, "also-not-copied.jpg")); + CHECK(unlink(path) == 0); + CHECK(rmdir(newline_source) == 0); + CHECK(rmdir(base) == 0); + return true; +} + +int +main(void) +{ + return run_test() ? EXIT_SUCCESS : EXIT_FAILURE; +}