From 118172ad0e019a7df3a4286d5064fe2d9ee9543c Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 6 Aug 2026 19:55:14 +0200 Subject: [PATCH] feat: add in-memory image catalog --- README.md | 4 + include/lardon3d/app_state.h | 6 + include/lardon3d/image_catalog.h | 36 +++ meson.build | 13 + src/app.c | 2 + src/image_catalog.c | 393 +++++++++++++++++++++++++++++++ src/layout.c | 77 +++++- src/project.c | 13 + src/tui.c | 159 ++++++++++++- tests/test_image_catalog.c | 302 ++++++++++++++++++++++++ 10 files changed, 996 insertions(+), 9 deletions(-) create mode 100644 include/lardon3d/image_catalog.h create mode 100644 src/image_catalog.c create mode 100644 tests/test_image_catalog.c diff --git a/README.md b/README.md index b4391c0..d5cabb6 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,7 @@ 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. + +L'écran Import présente un catalogue en mémoire vérifié par rapport au manifeste +et aux fichiers de `images/originals`. Les flèches, `j`/`k`, PageUp, PageDown, +Home et End naviguent dans la liste ; `R` recharge le catalogue. diff --git a/include/lardon3d/app_state.h b/include/lardon3d/app_state.h index 2934236..fef79df 100644 --- a/include/lardon3d/app_state.h +++ b/include/lardon3d/app_state.h @@ -3,6 +3,9 @@ #include #include +#include + +typedef struct Lardon3DImageCatalog Lardon3DImageCatalog; typedef enum { LARDON3D_SCREEN_HOME = 0, @@ -19,6 +22,9 @@ typedef struct { char project_name[128]; char project_path[PATH_MAX]; char status_message[256]; + Lardon3DImageCatalog *image_catalog; + size_t image_selection; + size_t image_offset; } Lardon3DAppState; void lardon3d_app_state_init(Lardon3DAppState *state); diff --git a/include/lardon3d/image_catalog.h b/include/lardon3d/image_catalog.h new file mode 100644 index 0000000..021bf88 --- /dev/null +++ b/include/lardon3d/image_catalog.h @@ -0,0 +1,36 @@ +#ifndef LARDON3D_IMAGE_CATALOG_H +#define LARDON3D_IMAGE_CATALOG_H + +#include +#include + +#include + +typedef struct { + char *filename; + char *source_path; + uint64_t size_bytes; +} Lardon3DImageEntry; + +Lardon3DImageCatalog *lardon3d_image_catalog_load( + const Lardon3DAppState *state, + char *error_message, + size_t error_message_size +); + +void lardon3d_image_catalog_destroy(Lardon3DImageCatalog *catalog); +size_t lardon3d_image_catalog_count(const Lardon3DImageCatalog *catalog); +uint64_t lardon3d_image_catalog_total_size( + const Lardon3DImageCatalog *catalog +); +const Lardon3DImageEntry *lardon3d_image_catalog_get( + const Lardon3DImageCatalog *catalog, + size_t index +); +void lardon3d_image_catalog_format_size( + uint64_t size_bytes, + char *text, + size_t text_size +); + +#endif diff --git a/meson.build b/meson.build index 71f1f6a..e8ee844 100644 --- a/meson.build +++ b/meson.build @@ -31,6 +31,7 @@ executable( 'src/layout.c', 'src/import.c', 'src/import_task.c', + 'src/image_catalog.c', 'src/project.c', ], include_directories: include_directories('include'), @@ -62,3 +63,15 @@ import_task_test = executable( ) test('import-task', import_task_test, timeout: 30) + +image_catalog_test = executable( + 'test-image-catalog', + sources: [ + 'tests/test_image_catalog.c', + 'src/app_state.c', + 'src/image_catalog.c', + ], + include_directories: include_directories('include'), +) + +test('image-catalog', image_catalog_test, timeout: 30) diff --git a/src/app.c b/src/app.c index e4ee278..889b54e 100644 --- a/src/app.c +++ b/src/app.c @@ -3,6 +3,7 @@ #include #include +#include #include int @@ -20,6 +21,7 @@ lardon3d_app_run(void) } bool success = lardon3d_tui_run(&state); + lardon3d_image_catalog_destroy(state.image_catalog); lardon3d_tui_shutdown(); return success ? EXIT_SUCCESS : EXIT_FAILURE; diff --git a/src/image_catalog.c b/src/image_catalog.c new file mode 100644 index 0000000..7b2db7a --- /dev/null +++ b/src/image_catalog.c @@ -0,0 +1,393 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +struct Lardon3DImageCatalog { + Lardon3DImageEntry *entries; + size_t count; + size_t capacity; + uint64_t total_size; +}; + +static void +set_error(char *message, size_t size, const char *text) +{ + if (message && size > 0) { + (void)snprintf(message, size, "%s", text); + } +} + +static char * +join_path(const char *parent, const char *child) +{ + size_t parent_length = strlen(parent); + size_t child_length = strlen(child); + if (parent_length > SIZE_MAX - child_length - 2) { + return NULL; + } + size_t size = parent_length + child_length + 2; + char *path = malloc(size); + if (!path) { + return NULL; + } + int written = snprintf(path, size, "%s/%s", parent, child); + if (written < 0 || (size_t)written >= size) { + free(path); + return NULL; + } + return path; +} + +static bool +parse_size(const char *text, uint64_t *value) +{ + if (!text[0]) { + return false; + } + uint64_t parsed = 0; + for (const unsigned char *digit = (const unsigned char *)text; + *digit; + ++digit) { + if (*digit < '0' || *digit > '9') { + return false; + } + uint64_t number = (uint64_t)(*digit - '0'); + if (parsed > (UINT64_MAX - number) / 10) { + return false; + } + parsed = parsed * 10 + number; + } + *value = parsed; + return true; +} + +static bool +valid_filename(const char *filename) +{ + return filename[0] + && !strchr(filename, '/') + && !strchr(filename, '\\') + && !strchr(filename, '\t') + && !strchr(filename, '\r') + && !strchr(filename, '\n'); +} + +static bool +filename_exists( + const Lardon3DImageCatalog *catalog, + const char *filename +) +{ + for (size_t index = 0; index < catalog->count; ++index) { + if (strcmp(catalog->entries[index].filename, filename) == 0) { + return true; + } + } + return false; +} + +static bool +append_entry( + Lardon3DImageCatalog *catalog, + const char *filename, + const char *source_path, + uint64_t size_bytes +) +{ + if (catalog->count == catalog->capacity) { + size_t capacity = catalog->capacity == 0 ? 16 : catalog->capacity * 2; + if (capacity < catalog->capacity + || capacity > SIZE_MAX / sizeof(*catalog->entries)) { + return false; + } + void *entries = realloc( + catalog->entries, + capacity * sizeof(*catalog->entries) + ); + if (!entries) { + return false; + } + catalog->entries = entries; + catalog->capacity = capacity; + } + + char *filename_copy = strdup(filename); + char *source_copy = strdup(source_path); + if (!filename_copy || !source_copy) { + free(filename_copy); + free(source_copy); + return false; + } + catalog->entries[catalog->count] = (Lardon3DImageEntry) { + .filename = filename_copy, + .source_path = source_copy, + .size_bytes = size_bytes, + }; + ++catalog->count; + catalog->total_size += size_bytes; + return true; +} + +void +lardon3d_image_catalog_destroy(Lardon3DImageCatalog *catalog) +{ + if (!catalog) { + return; + } + for (size_t index = 0; index < catalog->count; ++index) { + free(catalog->entries[index].filename); + free(catalog->entries[index].source_path); + } + free(catalog->entries); + free(catalog); +} + +static bool +validate_file( + const char *originals_path, + const char *filename, + uint64_t expected_size, + char *error_message, + size_t error_message_size +) +{ + char *path = join_path(originals_path, filename); + if (!path) { + set_error(error_message, error_message_size, "Erreur : chemin d'image trop long."); + return false; + } + struct stat info; + if (lstat(path, &info) != 0) { + free(path); + set_error(error_message, error_message_size, "Erreur : image du manifeste absente."); + return false; + } + free(path); + if (S_ISLNK(info.st_mode) || !S_ISREG(info.st_mode)) { + set_error(error_message, error_message_size, "Erreur : image du manifeste non régulière."); + return false; + } + if (info.st_size < 0 || (uintmax_t)info.st_size != (uintmax_t)expected_size) { + set_error(error_message, error_message_size, "Erreur : taille d'image incohérente."); + return false; + } + return true; +} + +static bool +parse_line( + Lardon3DImageCatalog *catalog, + char *line, + const char *originals_path, + char *error_message, + size_t error_message_size +) +{ + char *first_tab = strchr(line, '\t'); + char *second_tab = first_tab ? strchr(first_tab + 1, '\t') : NULL; + if (!first_tab || !second_tab || strchr(second_tab + 1, '\t')) { + set_error(error_message, error_message_size, "Erreur : ligne de manifeste invalide."); + return false; + } + *first_tab = '\0'; + *second_tab = '\0'; + const char *filename = line; + const char *size_text = first_tab + 1; + const char *source_path = second_tab + 1; + + uint64_t size_bytes; + if (!valid_filename(filename)) { + set_error(error_message, error_message_size, "Erreur : nom d'image invalide dans le manifeste."); + return false; + } + if (!source_path[0]) { + set_error(error_message, error_message_size, "Erreur : chemin source vide dans le manifeste."); + return false; + } + if (!parse_size(size_text, &size_bytes)) { + set_error(error_message, error_message_size, "Erreur : taille invalide dans le manifeste."); + return false; + } + if (filename_exists(catalog, filename)) { + set_error(error_message, error_message_size, "Erreur : image dupliquée dans le manifeste."); + return false; + } + if (catalog->total_size > UINT64_MAX - size_bytes) { + set_error(error_message, error_message_size, "Erreur : taille totale du catalogue trop grande."); + return false; + } + if (!validate_file( + originals_path, + filename, + size_bytes, + error_message, + error_message_size + )) { + return false; + } + if (!append_entry(catalog, filename, source_path, size_bytes)) { + set_error(error_message, error_message_size, "Erreur : mémoire insuffisante pour le catalogue."); + return false; + } + return true; +} + +Lardon3DImageCatalog * +lardon3d_image_catalog_load( + const Lardon3DAppState *state, + char *error_message, + size_t error_message_size +) +{ + set_error(error_message, error_message_size, ""); + if (!state || !state->project_loaded || !state->project_path[0]) { + set_error(error_message, error_message_size, "Aucun projet chargé."); + return NULL; + } + + Lardon3DImageCatalog *catalog = calloc(1, sizeof(*catalog)); + if (!catalog) { + set_error(error_message, error_message_size, "Erreur : mémoire insuffisante pour le catalogue."); + return NULL; + } + char *images_path = join_path(state->project_path, "images"); + char *originals_path = images_path ? join_path(images_path, "originals") : NULL; + char *manifest_path = images_path ? join_path(images_path, "manifest.tsv") : NULL; + if (!images_path || !originals_path || !manifest_path) { + set_error(error_message, error_message_size, "Erreur : chemin du catalogue trop long."); + goto failure; + } + + int descriptor = open(manifest_path, O_RDONLY | O_NOFOLLOW); + if (descriptor < 0) { + if (errno == ENOENT) { + set_error(error_message, error_message_size, "Aucune image importée."); + free(images_path); + free(originals_path); + free(manifest_path); + return catalog; + } + set_error(error_message, error_message_size, "Erreur : impossible d'ouvrir manifest.tsv."); + goto failure; + } + struct stat manifest_info; + if (fstat(descriptor, &manifest_info) != 0 || !S_ISREG(manifest_info.st_mode)) { + (void)close(descriptor); + set_error(error_message, error_message_size, "Erreur : manifest.tsv n'est pas régulier."); + goto failure; + } + FILE *file = fdopen(descriptor, "r"); + if (!file) { + (void)close(descriptor); + set_error(error_message, error_message_size, "Erreur : impossible de lire manifest.tsv."); + goto failure; + } + + char *line = NULL; + size_t capacity = 0; + ssize_t length = getline(&line, &capacity, file); + bool valid = length >= 0 + && strcmp(line, "filename\tsize_bytes\tsource_path\n") == 0; + if (!valid) { + set_error(error_message, error_message_size, "Erreur : en-tête de manifest.tsv invalide."); + } + while (valid && (length = getline(&line, &capacity, file)) >= 0) { + if (length == 0 || line[(size_t)length - 1] != '\n') { + set_error(error_message, error_message_size, "Erreur : ligne tronquée dans manifest.tsv."); + valid = false; + break; + } + line[(size_t)length - 1] = '\0'; + if (strchr(line, '\r') || strchr(line, '\n') + || !parse_line( + catalog, + line, + originals_path, + error_message, + error_message_size + )) { + if (!error_message || !error_message_size || !error_message[0]) { + set_error(error_message, error_message_size, "Erreur : ligne de manifeste invalide."); + } + valid = false; + } + } + if (ferror(file) || fclose(file) != 0) { + set_error(error_message, error_message_size, "Erreur : lecture de manifest.tsv impossible."); + valid = false; + } + free(line); + free(images_path); + free(originals_path); + free(manifest_path); + if (!valid) { + lardon3d_image_catalog_destroy(catalog); + return NULL; + } + return catalog; + +failure: + free(images_path); + free(originals_path); + free(manifest_path); + lardon3d_image_catalog_destroy(catalog); + return NULL; +} + +size_t +lardon3d_image_catalog_count(const Lardon3DImageCatalog *catalog) +{ + return catalog ? catalog->count : 0; +} + +uint64_t +lardon3d_image_catalog_total_size(const Lardon3DImageCatalog *catalog) +{ + return catalog ? catalog->total_size : 0; +} + +const Lardon3DImageEntry * +lardon3d_image_catalog_get( + const Lardon3DImageCatalog *catalog, + size_t index +) +{ + return catalog && index < catalog->count ? &catalog->entries[index] : NULL; +} + +void +lardon3d_image_catalog_format_size( + uint64_t size_bytes, + char *text, + size_t text_size +) +{ + if (!text || text_size == 0) { + return; + } + static const char *units[] = {"octets", "Kio", "Mio", "Gio"}; + long double value = (long double)size_bytes; + size_t unit = 0; + while (value >= 1024.0L && unit + 1 < sizeof(units) / sizeof(units[0])) { + value /= 1024.0L; + ++unit; + } + if (unit == 0) { + (void)snprintf(text, text_size, "%" PRIu64 " %s", size_bytes, units[unit]); + } else { + (void)snprintf(text, text_size, "%.1Lf %s", value, units[unit]); + char *decimal = strchr(text, '.'); + if (decimal) { + *decimal = ','; + } + } +} diff --git a/src/layout.c b/src/layout.c index ba5c39b..124923c 100644 --- a/src/layout.c +++ b/src/layout.c @@ -3,6 +3,7 @@ #include #include +#include enum { MINIMUM_ROWS = 20, @@ -70,7 +71,7 @@ screen_texts( case LARDON3D_SCREEN_IMPORT: *title = "Import"; *content = "Import des images"; - *footer = "I Importer des images ESC Accueil Q Quit"; + *footer = "I Importer R Recharger ↑/↓ Naviguer ESC Accueil Q Quit"; break; case LARDON3D_SCREEN_VIEWER: *title = "Viewer"; @@ -131,11 +132,74 @@ draw_project_screen( draw_input_field(input_text, input_label, 11, columns); } +static void +draw_catalog( + const Lardon3DAppState *state, + int rows, + int columns +) +{ + if (!state->project_loaded) { + draw_text(7, 4, columns - 6, "Aucun projet chargé."); + return; + } + size_t count = lardon3d_image_catalog_count(state->image_catalog); + char line[512]; + (void)snprintf( + line, + sizeof(line), + "Images importées : %zu", + count + ); + draw_text(6, 4, columns - 6, line); + char size_text[64]; + lardon3d_image_catalog_format_size( + lardon3d_image_catalog_total_size(state->image_catalog), + size_text, + sizeof(size_text) + ); + (void)snprintf(line, sizeof(line), "Taille totale : %s", size_text); + draw_text(7, 4, columns - 6, line); + if (count == 0) { + draw_text(9, 4, columns - 6, "Aucune image importée."); + return; + } + + int journal_row = rows - 7; + size_t visible = journal_row > 9 ? (size_t)(journal_row - 9) : 0; + for (size_t row = 0; row < visible; ++row) { + size_t index = state->image_offset + row; + const Lardon3DImageEntry *entry = lardon3d_image_catalog_get( + state->image_catalog, + index + ); + if (!entry) { + break; + } + lardon3d_image_catalog_format_size( + entry->size_bytes, + size_text, + sizeof(size_text) + ); + (void)snprintf( + line, + sizeof(line), + "%c %s %s", + index == state->image_selection ? '>' : ' ', + entry->filename, + size_text + ); + draw_text(9 + (int)row, 4, columns - 6, line); + } +} + static void draw_import_screen( + const Lardon3DAppState *state, const char *input_text, const char *input_label, const Lardon3DImportTaskSnapshot *snapshot, + int rows, int columns ) { @@ -191,10 +255,11 @@ draw_import_screen( 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"); - draw_input_field(input_text, input_label, 10, columns); + if (input_text) { + draw_input_field(input_text, input_label, 7, columns); + return; + } + draw_catalog(state, rows, columns); } static void @@ -237,9 +302,11 @@ draw_content( ); } else if (state->screen == LARDON3D_SCREEN_IMPORT) { draw_import_screen( + state, input_text, input_label, import_snapshot, + rows, columns ); } else { diff --git a/src/project.c b/src/project.c index 9246252..83d1fcc 100644 --- a/src/project.c +++ b/src/project.c @@ -10,6 +10,7 @@ #include #include +#include enum { MAX_CREATED_DIRECTORIES = 16, @@ -32,6 +33,15 @@ set_status(Lardon3DAppState *state, const char *message) ); } +static void +clear_catalog(Lardon3DAppState *state) +{ + lardon3d_image_catalog_destroy(state->image_catalog); + state->image_catalog = NULL; + state->image_selection = 0; + state->image_offset = 0; +} + static bool normalize_name( Lardon3DAppState *state, @@ -346,6 +356,7 @@ lardon3d_project_create(Lardon3DAppState *state, const char *name) return false; } + clear_catalog(state); state->project_loaded = true; (void)copy_path( state->project_name, @@ -495,6 +506,7 @@ lardon3d_project_open( return false; } + clear_catalog(state); state->project_loaded = true; (void)copy_path( state->project_name, @@ -527,6 +539,7 @@ lardon3d_project_close(Lardon3DAppState *state) return; } + clear_catalog(state); state->project_loaded = false; state->project_name[0] = '\0'; state->project_path[0] = '\0'; diff --git a/src/tui.c b/src/tui.c index 7537885..cd9fa1a 100644 --- a/src/tui.c +++ b/src/tui.c @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -27,6 +28,70 @@ typedef struct { size_t length; } TuiInput; +static size_t +catalog_page_size(void) +{ + int rows; + int columns; + getmaxyx(stdscr, rows, columns); + (void)columns; + return rows > 16 ? (size_t)(rows - 16) : 1; +} + +static void +normalize_catalog_navigation(Lardon3DAppState *state) +{ + size_t count = lardon3d_image_catalog_count(state->image_catalog); + if (count == 0) { + state->image_selection = 0; + state->image_offset = 0; + return; + } + if (state->image_selection >= count) { + state->image_selection = count - 1; + } + if (state->image_offset > state->image_selection) { + state->image_offset = state->image_selection; + } + size_t page_size = catalog_page_size(); + if (state->image_selection - state->image_offset >= page_size) { + state->image_offset = state->image_selection - page_size + 1; + } +} + +static bool +reload_catalog(Lardon3DAppState *state, bool announce_success) +{ + lardon3d_image_catalog_destroy(state->image_catalog); + state->image_catalog = NULL; + char message[sizeof(state->status_message)]; + state->image_catalog = lardon3d_image_catalog_load( + state, + message, + sizeof(message) + ); + normalize_catalog_navigation(state); + if (!state->image_catalog || message[0]) { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "%s", + message + ); + return state->image_catalog != NULL; + } + if (announce_success) { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Catalogue rechargé : %zu image%s.", + lardon3d_image_catalog_count(state->image_catalog), + lardon3d_image_catalog_count(state->image_catalog) == 1 ? "" : "s" + ); + } + return true; +} + static void redraw( const Lardon3DAppState *state, @@ -135,11 +200,15 @@ handle_active_input( if (key == '\n' || key == '\r' || key == KEY_ENTER) { if (input->mode == INPUT_PROJECT_OPEN) { - (void)lardon3d_project_open(state, input->text); + if (lardon3d_project_open(state, input->text)) { + (void)reload_catalog(state, false); + } } else if (input->mode == INPUT_IMPORT_DIRECTORY) { (void)start_import_task(state, input, task); } else { - (void)lardon3d_project_create(state, input->text); + if (lardon3d_project_create(state, input->text)) { + (void)reload_catalog(state, false); + } } input->mode = INPUT_NONE; return true; @@ -156,7 +225,9 @@ handle_active_input( if (key >= 0 && key <= UCHAR_MAX && isprint((unsigned char)key)) { if (input->length + 1 >= sizeof(input->text)) { if (input->mode == INPUT_PROJECT_OPEN) { - (void)lardon3d_project_open(state, input->text); + if (lardon3d_project_open(state, input->text)) { + (void)reload_catalog(state, false); + } } else if (input->mode == INPUT_IMPORT_DIRECTORY) { (void)snprintf( state->status_message, @@ -164,7 +235,9 @@ handle_active_input( "Erreur : chemin source trop long." ); } else { - (void)lardon3d_project_create(state, input->text); + if (lardon3d_project_create(state, input->text)) { + (void)reload_catalog(state, false); + } } input->mode = INPUT_NONE; return true; @@ -228,7 +301,67 @@ handle_normal_input( state->screen = LARDON3D_SCREEN_HOME; return true; case KEY_RESIZE: + normalize_catalog_navigation(state); return true; + case KEY_UP: + case 'k': + if (state->screen == LARDON3D_SCREEN_IMPORT + && state->image_selection > 0) { + --state->image_selection; + normalize_catalog_navigation(state); + return true; + } + return false; + case KEY_DOWN: + case 'j': { + size_t count = lardon3d_image_catalog_count(state->image_catalog); + if (state->screen == LARDON3D_SCREEN_IMPORT + && state->image_selection + 1 < count) { + ++state->image_selection; + normalize_catalog_navigation(state); + return true; + } + return false; + } + case KEY_PPAGE: + if (state->screen == LARDON3D_SCREEN_IMPORT) { + size_t page_size = catalog_page_size(); + state->image_selection = state->image_selection > page_size + ? state->image_selection - page_size + : 0; + normalize_catalog_navigation(state); + return true; + } + return false; + case KEY_NPAGE: + if (state->screen == LARDON3D_SCREEN_IMPORT) { + size_t count = lardon3d_image_catalog_count(state->image_catalog); + if (count > 0) { + size_t remaining = count - 1 - state->image_selection; + size_t page_size = catalog_page_size(); + state->image_selection += remaining < page_size + ? remaining + : page_size; + normalize_catalog_navigation(state); + } + return true; + } + return false; + case KEY_HOME: + if (state->screen == LARDON3D_SCREEN_IMPORT) { + state->image_selection = 0; + normalize_catalog_navigation(state); + return true; + } + return false; + case KEY_END: + if (state->screen == LARDON3D_SCREEN_IMPORT) { + size_t count = lardon3d_image_catalog_count(state->image_catalog); + state->image_selection = count > 0 ? count - 1 : 0; + normalize_catalog_navigation(state); + return true; + } + return false; case 'n': case 'N': if (state->screen == LARDON3D_SCREEN_PROJECTS) { @@ -270,6 +403,21 @@ handle_normal_input( return true; } return false; + case 'r': + case 'R': + if (state->screen == LARDON3D_SCREEN_IMPORT) { + if (!state->project_loaded) { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Aucun projet chargé." + ); + } else { + (void)reload_catalog(state, true); + } + return true; + } + return false; case 'c': case 'C': if (state->screen == LARDON3D_SCREEN_PROJECTS) { @@ -327,6 +475,9 @@ lardon3d_tui_run(Lardon3DAppState *state) "%s", snapshot.message ); + if (snapshot.status == LARDON3D_IMPORT_TASK_SUCCEEDED) { + (void)reload_catalog(state, false); + } lardon3d_import_task_destroy(task); task = NULL; should_redraw = true; diff --git a/tests/test_image_catalog.c b/tests/test_image_catalog.c new file mode 100644 index 0000000..0dace7c --- /dev/null +++ b/tests/test_image_catalog.c @@ -0,0 +1,302 @@ +#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 char *content, size_t length) +{ + size_t total = 0; + while (total < length) { + ssize_t written = write(descriptor, content + total, length - 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, const char *content) +{ + int descriptor = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); + if (descriptor < 0) { + return false; + } + bool success = write_all(descriptor, content, strlen(content)); + if (close(descriptor) != 0) { + success = false; + } + return success; +} + +static bool +replace_manifest(const char *path, const char *content) +{ + if (unlink(path) != 0 && errno != ENOENT) { + return false; + } + return create_file(path, content); +} + +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 +expect_invalid( + const Lardon3DAppState *state, + const char *manifest, + const char *content +) +{ + CHECK(replace_manifest(manifest, content)); + char error[256]; + Lardon3DImageCatalog *catalog = lardon3d_image_catalog_load( + state, + error, + sizeof(error) + ); + CHECK(!catalog); + CHECK(error[0]); + lardon3d_image_catalog_destroy(catalog); + return true; +} + +static bool +test_sizes(void) +{ + char text[64]; + lardon3d_image_catalog_format_size(512, text, sizeof(text)); + CHECK(strcmp(text, "512 octets") == 0); + lardon3d_image_catalog_format_size(1536, text, sizeof(text)); + CHECK(strcmp(text, "1,5 Kio") == 0); + lardon3d_image_catalog_format_size(2 * 1024 * 1024, text, sizeof(text)); + CHECK(strcmp(text, "2,0 Mio") == 0); + lardon3d_image_catalog_format_size( + UINT64_C(3) * 1024 * 1024 * 1024, + text, + sizeof(text) + ); + CHECK(strcmp(text, "3,0 Gio") == 0); + lardon3d_image_catalog_format_size(0, NULL, 0); + return true; +} + +static bool +run_test(void) +{ + char base[] = "/tmp/lardon3d-catalog-test.XXXXXX"; + CHECK(mkdtemp(base)); + char project[PATH_MAX]; + char images[PATH_MAX]; + char originals[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(manifest, images, "manifest.tsv")); + CHECK(mkdir(project, 0755) == 0); + CHECK(mkdir(images, 0755) == 0); + CHECK(mkdir(originals, 0755) == 0); + + Lardon3DAppState state; + lardon3d_app_state_init(&state); + state.project_loaded = true; + CHECK(snprintf( + state.project_path, + sizeof(state.project_path), + "%s", + project + ) > 0); + + char error[256]; + Lardon3DImageCatalog *catalog = lardon3d_image_catalog_load( + &state, + error, + sizeof(error) + ); + CHECK(catalog); + CHECK(lardon3d_image_catalog_count(catalog) == 0); + CHECK(strcmp(error, "Aucune image importée.") == 0); + CHECK(lardon3d_image_catalog_get(catalog, 0) == NULL); + lardon3d_image_catalog_destroy(catalog); + lardon3d_image_catalog_destroy(NULL); + + char first[PATH_MAX]; + char utf8[PATH_MAX]; + char link_path[PATH_MAX]; + CHECK(join_path(first, originals, "première image.jpg")); + CHECK(join_path(utf8, originals, "été.png")); + CHECK(join_path(link_path, originals, "lien.jpg")); + CHECK(create_file(first, "abc")); + CHECK(create_file(utf8, "12345")); + + const char valid_manifest[] = + "filename\tsize_bytes\tsource_path\n" + "première image.jpg\t3\t/tmp/source avec espaces/première image.jpg\n" + "été.png\t5\t/tmp/source/été.png\n"; + CHECK(replace_manifest(manifest, valid_manifest)); + catalog = lardon3d_image_catalog_load(&state, error, sizeof(error)); + CHECK(catalog); + CHECK(error[0] == '\0'); + CHECK(lardon3d_image_catalog_count(catalog) == 2); + CHECK(lardon3d_image_catalog_total_size(catalog) == 8); + const Lardon3DImageEntry *entry = lardon3d_image_catalog_get(catalog, 0); + CHECK(entry); + CHECK(strcmp(entry->filename, "première image.jpg") == 0); + CHECK(strcmp( + entry->source_path, + "/tmp/source avec espaces/première image.jpg" + ) == 0); + CHECK(entry->size_bytes == 3); + entry = lardon3d_image_catalog_get(catalog, 1); + CHECK(entry && strcmp(entry->filename, "été.png") == 0); + CHECK(lardon3d_image_catalog_get(catalog, 2) == NULL); + lardon3d_image_catalog_destroy(catalog); + + CHECK(expect_invalid( + &state, + manifest, + "bad\theader\npremière image.jpg\t3\t/tmp/source\n" + )); + CHECK(expect_invalid( + &state, + manifest, + "filename\tsize_bytes\tsource_path\npremière image.jpg\t3\n" + )); + CHECK(expect_invalid( + &state, + manifest, + "filename\tsize_bytes\tsource_path\npremière image.jpg\t3\t/a\textra\n" + )); + CHECK(expect_invalid( + &state, + manifest, + "filename\tsize_bytes\tsource_path\npremière image.jpg\t12x\t/a\n" + )); + CHECK(expect_invalid( + &state, + manifest, + "filename\tsize_bytes\tsource_path\n" + "première image.jpg\t18446744073709551616\t/a\n" + )); + CHECK(expect_invalid( + &state, + manifest, + "filename\tsize_bytes\tsource_path\n\t3\t/a\n" + )); + CHECK(expect_invalid( + &state, + manifest, + "filename\tsize_bytes\tsource_path\ndir/image.jpg\t3\t/a\n" + )); + CHECK(expect_invalid( + &state, + manifest, + "filename\tsize_bytes\tsource_path\ndir\\image.jpg\t3\t/a\n" + )); + CHECK(expect_invalid( + &state, + manifest, + "filename\tsize_bytes\tsource_path\npremière image.jpg\t3\t\n" + )); + CHECK(expect_invalid( + &state, + manifest, + "filename\tsize_bytes\tsource_path\n" + "première image.jpg\t3\t/a\npremière image.jpg\t3\t/b\n" + )); + CHECK(expect_invalid( + &state, + manifest, + "filename\tsize_bytes\tsource_path\nabsente.jpg\t1\t/a\n" + )); + CHECK(symlink(first, link_path) == 0); + CHECK(expect_invalid( + &state, + manifest, + "filename\tsize_bytes\tsource_path\nlien.jpg\t3\t/a\n" + )); + CHECK(expect_invalid( + &state, + manifest, + "filename\tsize_bytes\tsource_path\npremière image.jpg\t4\t/a\n" + )); + CHECK(expect_invalid( + &state, + manifest, + "filename\tsize_bytes\tsource_path\npremière image.jpg\t3\t/a" + )); + + CHECK(replace_manifest(manifest, valid_manifest)); + catalog = lardon3d_image_catalog_load(&state, error, sizeof(error)); + CHECK(catalog && lardon3d_image_catalog_count(catalog) == 2); + lardon3d_image_catalog_destroy(catalog); + CHECK(test_sizes()); + CHECK(remove_tree(base)); + return true; +} + +int +main(void) +{ + return run_test() ? EXIT_SUCCESS : EXIT_FAILURE; +}