feat: add safe image import with manifest

This commit is contained in:
fy59 2026-08-06 18:34:24 +02:00
parent 2cc632c75e
commit f2efe418cb
9 changed files with 1264 additions and 63 deletions

View file

@ -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`.

22
include/lardon3d/import.h Normal file
View file

@ -0,0 +1,22 @@
#ifndef LARDON3D_IMPORT_H
#define LARDON3D_IMPORT_H
#include <stdbool.h>
#include <stddef.h>
#include <lardon3d/app_state.h>
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

View file

@ -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
);

View file

@ -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)

812
src/import.c Normal file
View file

@ -0,0 +1,812 @@
#include <ctype.h>
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <lardon3d/import.h>
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;
}

View file

@ -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
);

View file

@ -1,5 +1,3 @@
#define _POSIX_C_SOURCE 200809L
#include <ctype.h>
#include <errno.h>
#include <fcntl.h>

View file

@ -4,40 +4,46 @@
#include <stdbool.h>
#include <stdio.h>
#include <lardon3d/import.h>
#include <lardon3d/layout.h>
#include <lardon3d/project.h>
#include <lardon3d/tui.h>
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) {

291
tests/test_import.c Normal file
View file

@ -0,0 +1,291 @@
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <lardon3d/app_state.h>
#include <lardon3d/import.h>
#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;
}