From 33a4a8be10580fe51cd7428a7eedd193582c2782 Mon Sep 17 00:00:00 2001 From: grayTerminal-sh Date: Thu, 23 Jul 2026 13:58:23 +0200 Subject: [PATCH] feat(graph): integrate dropped extractions --- CHANGELOG.md | 3 + Makefile | 24 +- README.md | 3 + TICKET-105.md | 45 --- include/core/extraction_drop_service.h | 76 +++++ include/views/main_window.h | 12 + include/widgets/investigation_graph_view.h | 13 + include/widgets/workspace.h | 12 + src/core/application.c | 117 +++++++ src/core/extraction_drop_service.c | 361 +++++++++++++++++++++ src/views/main_window.c | 24 ++ src/widgets/entity_details_panel.c | 12 +- src/widgets/investigation_graph_view.c | 57 ++++ src/widgets/investigation_tree_view.c | 49 +++ src/widgets/workspace.c | 24 ++ tests/test_extraction_drop_service.c | 183 +++++++++++ 16 files changed, 964 insertions(+), 51 deletions(-) delete mode 100644 TICKET-105.md create mode 100644 include/core/extraction_drop_service.h create mode 100644 src/core/extraction_drop_service.c create mode 100644 tests/test_extraction_drop_service.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 7356212..d6bc910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ### Added +- Glisser-déposer confirmé des extractions texte vers le graphe, avec + persistance de leur association à une entité. + - Initialisation du cycle de vie de l'application. - Création du module `Application`. - Intégration de GTK4. diff --git a/Makefile b/Makefile index 8114b27..06144ea 100644 --- a/Makefile +++ b/Makefile @@ -128,6 +128,7 @@ TEST_EML_ANALYZER := tests/test_eml_analyzer TEST_IBAN_ANALYZER := tests/test_iban_analyzer TEST_EXIFTOOL_METADATA := tests/test_exiftool_metadata TEST_PDF_PASSWORD_RECOVERY := tests/test_pdf_password_recovery +TEST_EXTRACTION_DROP_SERVICE := tests/test_extraction_drop_service all: $(TARGET) @@ -197,6 +198,22 @@ $(TEST_ERROR): \ src/database/error.c $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) -lsqlite3 +$(TEST_EXTRACTION_DROP_SERVICE): \ + tests/test_extraction_drop_service.c \ + src/core/extraction_drop_service.c \ + src/core/file_hash.c \ + src/dao/evidence_dao.c \ + src/dao/entity_dao.c \ + src/dao/evidence_entity_dao.c \ + src/models/evidence_record.c \ + src/models/entity_record.c \ + src/database/database.c \ + src/database/schema.c \ + src/database/statement.c \ + src/database/transaction.c \ + src/database/error.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) -lsqlite3 + $(TEST_INVESTIGATION_RECORD): \ tests/test_investigation_record.c \ src/models/investigation_record.c @@ -746,7 +763,8 @@ test: \ $(TEST_EML_ANALYZER) \ $(TEST_IBAN_ANALYZER) \ $(TEST_EXIFTOOL_METADATA) \ - $(TEST_PDF_PASSWORD_RECOVERY) + $(TEST_PDF_PASSWORD_RECOVERY) \ + $(TEST_EXTRACTION_DROP_SERVICE) @echo "Exécution des tests..." @./$(TEST_NODE) @./$(TEST_TREE_MODEL) @@ -809,6 +827,7 @@ test: \ @$(TEST_IBAN_ANALYZER) @$(TEST_EXIFTOOL_METADATA) @$(TEST_PDF_PASSWORD_RECOVERY) + @$(TEST_EXTRACTION_DROP_SERVICE) @echo "Tous les tests sont valides." %.o: %.c @@ -874,7 +893,8 @@ clean: $(TEST_SOCIAL_ACCOUNT_SERVICE) \ $(TEST_SOCIAL_PLATFORM) \ $(TEST_PERSON_ENTITY_SERVICE) \ - $(TEST_EML_ANALYZER) + $(TEST_EML_ANALYZER) \ + $(TEST_EXTRACTION_DROP_SERVICE) -include $(DEP) diff --git a/README.md b/README.md index eb3cc2f..9fdfb74 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,9 @@ Le socle actuel comprend notamment : sorties standard et d'erreur, et objets créés ou réutilisés ; - vérification manuelle de l'intégrité des sorties OSINT enregistrées, sans réécriture de l'empreinte ou des données contrôlées. +- glisser-déposer des extractions texte depuis l'arborescence vers le graphe, + avec confirmation explicite, création d'entité ou rattachement à une entité + existante, sans déplacement du fichier produit ; Les outils actuellement présents dans le catalogue initial sont : diff --git a/TICKET-105.md b/TICKET-105.md deleted file mode 100644 index c867230..0000000 --- a/TICKET-105.md +++ /dev/null @@ -1,45 +0,0 @@ -# Ticket 105 — Glisser-déposer des extractions vers le graphe - -## Objectif - -Permettre de prendre une extraction depuis `Dossier → Extractions` et de la -déposer dans le graphe afin de créer ou compléter les liens de l’enquête. - -## Fonctionnement attendu - -1. Une extraction sélectionnée dans la Sidebar peut être déplacée. -2. Le graphe accepte le dépôt uniquement sur une zone valide. -3. Une fenêtre de confirmation affiche le fichier, sa source et son contenu - textuel disponible. -4. L’utilisateur choisit explicitement : - - créer une nouvelle entité à partir de l’extraction ; - - rattacher l’extraction à une entité existante ; - - annuler. -5. L’association est enregistrée en base et visible dans la fiche concernée. -6. Une relation graphique peut être créée sans déplacer ni dupliquer le - fichier original. - -## Contraintes - -- Ne jamais créer de relation silencieusement. -- Refuser les fichiers hors du dossier de l’enquête. -- Conserver les associations déjà présentes. -- Utiliser les API GTK de glisser-déposer et les commentaires Doxygen prévus - par le projet. - -## Critères d’acceptation - -- Un fichier `.txt` d’OSINT est déplaçable depuis la Sidebar. -- Le dépôt sur le graphe ouvre le dialogue de choix. -- L’entité ou l’association créée survit à la réouverture de l’enquête. -- Les dépôts annulés ne modifient ni la base ni le graphe. -- Tests unitaires des chemins invalides, du choix d’action et de la - persistance. - -## Vérification - -```sh -make -j2 -make test -``` - diff --git a/include/core/extraction_drop_service.h b/include/core/extraction_drop_service.h new file mode 100644 index 0000000..4b55d79 --- /dev/null +++ b/include/core/extraction_drop_service.h @@ -0,0 +1,76 @@ +/****************************************************************************** + * @file extraction_drop_service.h + * @brief Validation et intégration d'une extraction déposée sur le graphe. + ******************************************************************************/ + +#ifndef LABFY_INVESTIGATION_EXTRACTION_DROP_SERVICE_H +#define LABFY_INVESTIGATION_EXTRACTION_DROP_SERVICE_H + +#include "database/database.h" + +#include + +G_BEGIN_DECLS + +typedef struct ExtractionDropInfo ExtractionDropInfo; + +typedef enum +{ + EXTRACTION_DROP_SERVICE_ERROR_INVALID_ARGUMENT, + EXTRACTION_DROP_SERVICE_ERROR_OUTSIDE_INVESTIGATION, + EXTRACTION_DROP_SERVICE_ERROR_INVALID_FILE, + EXTRACTION_DROP_SERVICE_ERROR_READ, + EXTRACTION_DROP_SERVICE_ERROR_DATABASE +} ExtractionDropServiceError; + +#define EXTRACTION_DROP_SERVICE_ERROR extraction_drop_service_error_quark() + +GQuark extraction_drop_service_error_quark(void); + +/** + * @brief Valide et lit une extraction texte appartenant à l'enquête. + * + * Le fichier doit être un `.txt` régulier situé sous + * `02_Preuves_Traitees/Extractions`. Aucun fichier n'est déplacé ou modifié. + */ +ExtractionDropInfo *extraction_drop_service_prepare( + const char *investigation_root, + const char *file_path, + GError **error +); + +void extraction_drop_info_free(ExtractionDropInfo *info); +const char *extraction_drop_info_get_file_path(const ExtractionDropInfo *info); +const char *extraction_drop_info_get_relative_path( + const ExtractionDropInfo *info); +const char *extraction_drop_info_get_file_name(const ExtractionDropInfo *info); +const char *extraction_drop_info_get_source(const ExtractionDropInfo *info); +const char *extraction_drop_info_get_content(const ExtractionDropInfo *info); + +/** + * @brief Enregistre l'extraction comme preuve traitée et la lie à une entité. + * + * Une preuve ou une association déjà présente est réutilisée. + */ +gboolean extraction_drop_service_attach( + Database *database, + const ExtractionDropInfo *info, + const char *entity_identifier, + GError **error +); + +/** + * @brief Crée une entité générique puis lui associe l'extraction. + * + * @return UUID nouvellement alloué dans out_entity_identifier, si demandé. + */ +gboolean extraction_drop_service_create_entity( + Database *database, + const ExtractionDropInfo *info, + char **out_entity_identifier, + GError **error +); + +G_END_DECLS + +#endif diff --git a/include/views/main_window.h b/include/views/main_window.h index 7dcb950..870d742 100644 --- a/include/views/main_window.h +++ b/include/views/main_window.h @@ -126,6 +126,12 @@ typedef void (*MainWindowGraphNodeMovedCallback)( gpointer user_data ); +typedef void (*MainWindowExtractionDropCallback)( + const char *file_path, + const char *target_entity_identifier, + gpointer user_data +); + /** * @brief Callback appelé lors d'une demande de réinitialisation du graphe. * @@ -246,6 +252,12 @@ void main_window_set_graph_node_moved_callback( gpointer user_data ); +void main_window_set_extraction_drop_callback( + MainWindow *main_window, + MainWindowExtractionDropCallback callback, + gpointer user_data +); + /** * @brief Définit le callback de demande de réinitialisation du graphe. * diff --git a/include/widgets/investigation_graph_view.h b/include/widgets/investigation_graph_view.h index 7f660f5..4d384f1 100644 --- a/include/widgets/investigation_graph_view.h +++ b/include/widgets/investigation_graph_view.h @@ -71,6 +71,13 @@ typedef void (*InvestigationGraphViewNodeMovedCallback)( gpointer user_data ); +/** @brief Callback appelé lorsqu'une extraction est déposée sur le graphe. */ +typedef void (*InvestigationGraphViewExtractionDropCallback)( + const char *file_path, + const char *target_entity_identifier, + gpointer user_data +); + /** * @brief Crée une nouvelle vue graphique vide. * @@ -170,6 +177,12 @@ void investigation_graph_view_set_node_moved_callback( gpointer user_data ); +void investigation_graph_view_set_extraction_drop_callback( + InvestigationGraphView *graph_view, + InvestigationGraphViewExtractionDropCallback callback, + gpointer user_data +); + /** * @brief Retourne l'entité actuellement sélectionnée. * diff --git a/include/widgets/workspace.h b/include/widgets/workspace.h index fb5a90c..580cab5 100644 --- a/include/widgets/workspace.h +++ b/include/widgets/workspace.h @@ -56,6 +56,12 @@ typedef void (*WorkspaceGraphNodeMovedCallback)( gpointer user_data ); +typedef void (*WorkspaceExtractionDropCallback)( + const char *file_path, + const char *target_entity_identifier, + gpointer user_data +); + /** * @brief Callback appelé lors d'une demande de réinitialisation du graphe. * @@ -317,6 +323,12 @@ void workspace_set_graph_node_moved_callback( gpointer user_data ); +void workspace_set_extraction_drop_callback( + Workspace *workspace, + WorkspaceExtractionDropCallback callback, + gpointer user_data +); + /** * @brief Définit le callback de demande de réinitialisation du graphe. * diff --git a/src/core/application.c b/src/core/application.c index 1dc4701..806df51 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -67,6 +67,7 @@ #include "core/exiftool_metadata.h" #include "views/metadata_analysis_dialog.h" #include "core/pdf_password_recovery.h" +#include "core/extraction_drop_service.h" #include "views/pdf_password_dialog.h" #include @@ -225,6 +226,13 @@ typedef struct char *started_at; } ApplicationOsintActionContext; +typedef struct +{ + Application *application; + ExtractionDropInfo *info; + char *target_entity_identifier; +} ApplicationExtractionDropContext; + /** @brief Données possédées par l'étape de révision OSINT. */ typedef struct { @@ -250,6 +258,112 @@ static void application_present_error( const char *message ); +static void application_extraction_drop_context_free( + ApplicationExtractionDropContext *context) +{ + if (context == NULL) return; + extraction_drop_info_free(context->info); + g_free(context->target_entity_identifier); + g_free(context); +} + +static void application_on_extraction_choice_finished( + GObject *source_object, GAsyncResult *result, gpointer user_data) +{ + ApplicationExtractionDropContext *context = user_data; + Application *application = context != NULL ? context->application : NULL; + GtkAlertDialog *dialog = GTK_ALERT_DIALOG(source_object); + const InvestigationProject *project = NULL; + Database *database = NULL; + GError *error = NULL; + int choice = gtk_alert_dialog_choose_finish(dialog, result, &error); + gboolean success = FALSE; + + if (application == NULL || application->session == NULL) goto cleanup; + if (error != NULL || choice == 0) + { + main_window_set_status(application->main_window, + "Dépôt de l'extraction annulé."); + goto cleanup; + } + database = investigation_session_get_database(application->session); + if (choice == 1) + success = extraction_drop_service_create_entity(database, + context->info, NULL, &error); + else if (choice == 2 && context->target_entity_identifier != NULL) + success = extraction_drop_service_attach(database, context->info, + context->target_entity_identifier, &error); + else + g_set_error_literal(&error, EXTRACTION_DROP_SERVICE_ERROR, + EXTRACTION_DROP_SERVICE_ERROR_INVALID_ARGUMENT, + "Déposez l'extraction directement sur une entité pour la rattacher."); + + if (!success) + { + application_present_error(application, + "Intégration de l'extraction impossible", + error != NULL ? error->message : "L'association n'a pas été créée."); + goto cleanup; + } + main_window_set_status(application->main_window, + choice == 1 ? "Entité créée depuis l'extraction." + : "Extraction rattachée à l'entité."); + project = investigation_session_get_project(application->session); + if (project != NULL) + application_start_graph_loading(application, + investigation_project_get_database_path(project)); + +cleanup: + g_clear_error(&error); + application_extraction_drop_context_free(context); +} + +static void application_on_extraction_dropped( + const char *file_path, + const char *target_entity_identifier, + gpointer user_data) +{ + Application *application = user_data; + ApplicationExtractionDropContext *context = NULL; + const InvestigationProject *project = NULL; + GtkAlertDialog *dialog = NULL; + const char *buttons[] = { + "Annuler", "Créer une entité", "Rattacher à l'entité", NULL + }; + char *details = NULL; + GError *error = NULL; + + if (application == NULL || application->session == NULL) return; + project = investigation_session_get_project(application->session); + context = g_new0(ApplicationExtractionDropContext, 1); + context->application = application; + context->target_entity_identifier = g_strdup(target_entity_identifier); + context->info = extraction_drop_service_prepare( + investigation_project_get_root_path(project), file_path, &error); + if (context->info == NULL) + { + application_present_error(application, "Dépôt refusé", + error != NULL ? error->message : "Extraction invalide."); + g_clear_error(&error); + application_extraction_drop_context_free(context); + return; + } + details = g_strdup_printf("Fichier : %s\nSource : %s\n\n%s", + extraction_drop_info_get_file_name(context->info), + extraction_drop_info_get_source(context->info), + extraction_drop_info_get_content(context->info)); + dialog = gtk_alert_dialog_new("Intégrer cette extraction au graphe ?"); + gtk_alert_dialog_set_detail(dialog, details); + gtk_alert_dialog_set_buttons(dialog, buttons); + gtk_alert_dialog_set_cancel_button(dialog, 0); + gtk_alert_dialog_set_default_button(dialog, 0); + gtk_alert_dialog_choose(dialog, + main_window_get_window(application->main_window), NULL, + application_on_extraction_choice_finished, context); + g_object_unref(dialog); + g_free(details); +} + static void application_present_next_batch_metadata( ApplicationEvidenceBatchContext *context ); @@ -7644,6 +7758,9 @@ static void application_on_activate( application_on_graph_node_moved, application ); + main_window_set_extraction_drop_callback( + application->main_window, application_on_extraction_dropped, + application); main_window_set_reset_graph_layout_callback( application->main_window, diff --git a/src/core/extraction_drop_service.c b/src/core/extraction_drop_service.c new file mode 100644 index 0000000..1a101ab --- /dev/null +++ b/src/core/extraction_drop_service.c @@ -0,0 +1,361 @@ +/****************************************************************************** + * @file extraction_drop_service.c + * @brief Validation et intégration d'une extraction déposée sur le graphe. + ******************************************************************************/ + +#include "core/extraction_drop_service.h" + +#include "core/file_hash.h" +#include "dao/entity_dao.h" +#include "dao/evidence_dao.h" +#include "dao/evidence_entity_dao.h" +#include "database/transaction.h" +#include "models/entity_record.h" +#include "models/evidence_record.h" + +#include + +struct ExtractionDropInfo +{ + char *file_path; + char *relative_path; + char *file_name; + char *source; + char *content; +}; + +GQuark extraction_drop_service_error_quark(void) +{ + return g_quark_from_static_string("extraction-drop-service-error"); +} + +static void extraction_drop_service_set_error( + GError **error, + ExtractionDropServiceError code, + const char *message) +{ + if (error != NULL && *error == NULL) + { + g_set_error_literal(error, EXTRACTION_DROP_SERVICE_ERROR, code, message); + } +} + +static char *extraction_drop_service_timestamp(void) +{ + GDateTime *now = g_date_time_new_now_utc(); + char *timestamp = now != NULL + ? g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ") : NULL; + g_clear_pointer(&now, g_date_time_unref); + return timestamp; +} + +static char *extraction_drop_service_source_from_content(const char *content) +{ + const char *line_end = NULL; + if (content == NULL || content[0] == '\0') + { + return g_strdup("Extraction locale"); + } + line_end = strchr(content, '\n'); + if (line_end != NULL && line_end > content) + { + return g_strndup(content, (gsize) (line_end - content)); + } + return g_strdup(content); +} + +ExtractionDropInfo *extraction_drop_service_prepare( + const char *investigation_root, + const char *file_path, + GError **error) +{ + ExtractionDropInfo *info = NULL; + char *canonical_root = NULL; + char *extractions_root = NULL; + char *canonical_extractions_root = NULL; + char *canonical_file = NULL; + char *content = NULL; + gsize content_length = 0U; + + g_return_val_if_fail(error == NULL || *error == NULL, NULL); + if (investigation_root == NULL || investigation_root[0] == '\0' || + file_path == NULL || file_path[0] == '\0') + { + extraction_drop_service_set_error(error, + EXTRACTION_DROP_SERVICE_ERROR_INVALID_ARGUMENT, + "Le chemin de l'extraction est invalide."); + return NULL; + } + + canonical_root = g_canonicalize_filename(investigation_root, NULL); + extractions_root = g_build_filename(canonical_root, + "02_Preuves_Traitees", "Extractions", NULL); + canonical_extractions_root = g_canonicalize_filename(extractions_root, NULL); + canonical_file = g_canonicalize_filename(file_path, NULL); + if (canonical_root == NULL || canonical_extractions_root == NULL || + canonical_file == NULL) + { + extraction_drop_service_set_error(error, + EXTRACTION_DROP_SERVICE_ERROR_INVALID_ARGUMENT, + "Impossible de normaliser le chemin de l'extraction."); + goto cleanup; + } + if (!g_str_has_prefix(canonical_file, canonical_extractions_root) || + canonical_file[strlen(canonical_extractions_root)] != G_DIR_SEPARATOR) + { + extraction_drop_service_set_error(error, + EXTRACTION_DROP_SERVICE_ERROR_OUTSIDE_INVESTIGATION, + "Le fichier ne se trouve pas dans les extractions de l'enquête."); + goto cleanup; + } + if (!g_file_test(canonical_file, G_FILE_TEST_IS_REGULAR) || + g_file_test(canonical_file, G_FILE_TEST_IS_SYMLINK) || + !g_str_has_suffix(canonical_file, ".txt")) + { + extraction_drop_service_set_error(error, + EXTRACTION_DROP_SERVICE_ERROR_INVALID_FILE, + "Seuls les fichiers texte réguliers d'extraction sont acceptés."); + goto cleanup; + } + if (!g_file_get_contents(canonical_file, &content, &content_length, error)) + { + if (error != NULL && *error != NULL) + { + (*error)->domain = EXTRACTION_DROP_SERVICE_ERROR; + (*error)->code = EXTRACTION_DROP_SERVICE_ERROR_READ; + } + goto cleanup; + } + if (!g_utf8_validate(content, (gssize) content_length, NULL)) + { + extraction_drop_service_set_error(error, + EXTRACTION_DROP_SERVICE_ERROR_INVALID_FILE, + "L'extraction texte n'est pas encodée en UTF-8."); + goto cleanup; + } + + info = g_new0(ExtractionDropInfo, 1); + info->file_path = g_strdup(canonical_file); + info->relative_path = g_strdup(canonical_file + strlen(canonical_root) + 1U); + info->file_name = g_path_get_basename(canonical_file); + info->content = g_steal_pointer(&content); + info->source = extraction_drop_service_source_from_content(info->content); + if (info->file_path == NULL || info->relative_path == NULL || + info->file_name == NULL || info->content == NULL || info->source == NULL) + { + extraction_drop_info_free(info); + info = NULL; + extraction_drop_service_set_error(error, + EXTRACTION_DROP_SERVICE_ERROR_INVALID_ARGUMENT, + "Impossible d'allouer les informations de l'extraction."); + } + +cleanup: + g_free(content); + g_free(canonical_file); + g_free(canonical_extractions_root); + g_free(extractions_root); + g_free(canonical_root); + return info; +} + +void extraction_drop_info_free(ExtractionDropInfo *info) +{ + if (info == NULL) return; + g_free(info->file_path); + g_free(info->relative_path); + g_free(info->file_name); + g_free(info->source); + g_free(info->content); + g_free(info); +} + +#define EXTRACTION_DROP_GETTER(name, field) \ +const char *name(const ExtractionDropInfo *info) \ +{ return info != NULL ? info->field : NULL; } + +EXTRACTION_DROP_GETTER(extraction_drop_info_get_file_path, file_path) +EXTRACTION_DROP_GETTER(extraction_drop_info_get_relative_path, relative_path) +EXTRACTION_DROP_GETTER(extraction_drop_info_get_file_name, file_name) +EXTRACTION_DROP_GETTER(extraction_drop_info_get_source, source) +EXTRACTION_DROP_GETTER(extraction_drop_info_get_content, content) + +static char *extraction_drop_service_ensure_evidence( + Database *database, + const ExtractionDropInfo *info, + GError **error) +{ + EvidenceDao *dao = NULL; + EvidenceRecord *record = NULL; + GPtrArray *records = NULL; + char *identifier = NULL; + char *sha256 = NULL; + char *timestamp = NULL; + guint64 size_bytes = 0U; + + dao = evidence_dao_new(database, error); + if (dao == NULL) goto cleanup; + records = evidence_dao_list_all(dao, error); + if (records == NULL) goto cleanup; + for (guint index = 0U; index < records->len; index++) + { + const EvidenceRecord *candidate = g_ptr_array_index(records, index); + if (g_strcmp0(evidence_record_get_relative_path(candidate), + info->relative_path) == 0) + { + identifier = g_strdup(evidence_record_get_identifier(candidate)); + goto cleanup; + } + } + if (!file_hash_compute_sha256(info->file_path, NULL, &sha256, + &size_bytes, error)) goto cleanup; + identifier = g_uuid_string_random(); + timestamp = extraction_drop_service_timestamp(); + record = evidence_record_new(identifier, info->file_name, info->file_name, + info->relative_path, "text", size_bytes, sha256, timestamp, NULL, + info->source, "Extraction intégrée depuis le graphe.", + EVIDENCE_INTEGRITY_STATUS_VALID, error); + if (record == NULL || !evidence_dao_insert(dao, record, error)) + { + g_clear_pointer(&identifier, g_free); + } + +cleanup: + evidence_record_free(record); + g_clear_pointer(&records, g_ptr_array_unref); + evidence_dao_free(dao); + g_free(timestamp); + g_free(sha256); + return identifier; +} + +static gboolean extraction_drop_service_attach_in_transaction( + Database *database, + const ExtractionDropInfo *info, + const char *entity_identifier, + GError **error) +{ + EvidenceEntityDao *link_dao = NULL; + char *evidence_identifier = NULL; + gboolean exists = FALSE; + gboolean success = FALSE; + + g_return_val_if_fail(error == NULL || *error == NULL, FALSE); + if (database == NULL || info == NULL || entity_identifier == NULL || + !g_uuid_string_is_valid(entity_identifier)) + { + extraction_drop_service_set_error(error, + EXTRACTION_DROP_SERVICE_ERROR_INVALID_ARGUMENT, + "L'entité de destination est invalide."); + return FALSE; + } + evidence_identifier = extraction_drop_service_ensure_evidence(database, + info, error); + if (evidence_identifier == NULL) goto cleanup; + link_dao = evidence_entity_dao_new(database, error); + if (link_dao == NULL || + !evidence_entity_dao_exists(link_dao, evidence_identifier, + entity_identifier, &exists, error) || + (!exists && !evidence_entity_dao_link(link_dao, evidence_identifier, + entity_identifier, error))) goto cleanup; + success = TRUE; +cleanup: + evidence_entity_dao_free(link_dao); + g_free(evidence_identifier); + return success; +} + +gboolean extraction_drop_service_attach( + Database *database, + const ExtractionDropInfo *info, + const char *entity_identifier, + GError **error) +{ + gboolean success = FALSE; + g_return_val_if_fail(error == NULL || *error == NULL, FALSE); + if (database == NULL || info == NULL || entity_identifier == NULL || + !g_uuid_string_is_valid(entity_identifier)) + { + extraction_drop_service_set_error(error, + EXTRACTION_DROP_SERVICE_ERROR_INVALID_ARGUMENT, + "L'entité de destination est invalide."); + return FALSE; + } + if (!database_transaction_begin(database)) goto database_failure; + if (!extraction_drop_service_attach_in_transaction(database, info, + entity_identifier, error)) + { + database_transaction_rollback(database); + return FALSE; + } + if (!database_transaction_commit(database)) goto database_failure; + success = TRUE; + return success; + +database_failure: + extraction_drop_service_set_error(error, + EXTRACTION_DROP_SERVICE_ERROR_DATABASE, + "Impossible d'enregistrer l'association de l'extraction."); + return FALSE; +} + +gboolean extraction_drop_service_create_entity( + Database *database, + const ExtractionDropInfo *info, + char **out_entity_identifier, + GError **error) +{ + EntityDao *entity_dao = NULL; + EntityRecord *entity = NULL; + char *identifier = NULL; + char *timestamp = NULL; + gboolean success = FALSE; + + g_return_val_if_fail(error == NULL || *error == NULL, FALSE); + if (out_entity_identifier != NULL) *out_entity_identifier = NULL; + if (database == NULL || info == NULL) + { + extraction_drop_service_set_error(error, + EXTRACTION_DROP_SERVICE_ERROR_INVALID_ARGUMENT, + "Les paramètres de création d'entité sont invalides."); + return FALSE; + } + identifier = g_uuid_string_random(); + timestamp = extraction_drop_service_timestamp(); + entity = entity_record_new(identifier, "other", info->file_name, + info->file_name, info->content, 50, timestamp, timestamp, + ENTITY_STATUS_ACTIVE, error); + if (!database_transaction_begin(database)) + { + extraction_drop_service_set_error(error, + EXTRACTION_DROP_SERVICE_ERROR_DATABASE, + "Impossible de démarrer la création de l'entité."); + goto cleanup; + } + entity_dao = entity_dao_new(database, error); + if (entity == NULL || entity_dao == NULL || + !entity_dao_insert(entity_dao, entity, error) || + !extraction_drop_service_attach_in_transaction(database, info, + identifier, error)) + { + database_transaction_rollback(database); + goto cleanup; + } + if (!database_transaction_commit(database)) + { + extraction_drop_service_set_error(error, + EXTRACTION_DROP_SERVICE_ERROR_DATABASE, + "Impossible de valider la création de l'entité."); + goto cleanup; + } + if (out_entity_identifier != NULL) + *out_entity_identifier = g_strdup(identifier); + success = TRUE; + +cleanup: + entity_dao_free(entity_dao); + entity_record_free(entity); + g_free(timestamp); + g_free(identifier); + return success; +} diff --git a/src/views/main_window.c b/src/views/main_window.c index 474b517..d78cf62 100644 --- a/src/views/main_window.c +++ b/src/views/main_window.c @@ -123,6 +123,9 @@ struct MainWindow gpointer graph_node_moved_user_data; + MainWindowExtractionDropCallback extraction_drop_callback; + gpointer extraction_drop_user_data; + MainWindowResetGraphLayoutCallback reset_graph_layout_callback; @@ -508,6 +511,15 @@ static void main_window_on_graph_node_moved( ); } +static void main_window_on_extraction_dropped(const char *file_path, + const char *target_entity_identifier, gpointer user_data) +{ + MainWindow *main_window = user_data; + if (main_window != NULL && main_window->extraction_drop_callback != NULL) + main_window->extraction_drop_callback(file_path, + target_entity_identifier, main_window->extraction_drop_user_data); +} + /** * @brief Relaie la demande de vérification provenant du Workspace. */ @@ -954,6 +966,8 @@ MainWindow *main_window_new( main_window_on_graph_node_moved, main_window ); + workspace_set_extraction_drop_callback(main_window->workspace, + main_window_on_extraction_dropped, main_window); workspace_set_reset_graph_layout_callback( main_window->workspace, @@ -1756,6 +1770,16 @@ void main_window_set_graph_node_moved_callback( user_data; } +void main_window_set_extraction_drop_callback( + MainWindow *main_window, + MainWindowExtractionDropCallback callback, + gpointer user_data) +{ + if (main_window == NULL) return; + main_window->extraction_drop_callback = callback; + main_window->extraction_drop_user_data = user_data; +} + void main_window_set_reset_graph_layout_callback( MainWindow *main_window, MainWindowResetGraphLayoutCallback callback, diff --git a/src/widgets/entity_details_panel.c b/src/widgets/entity_details_panel.c index 9245f77..7fccabd 100644 --- a/src/widgets/entity_details_panel.c +++ b/src/widgets/entity_details_panel.c @@ -39,6 +39,7 @@ struct EntityDetailsPanel GtkWidget *person_name_button; GtkWidget *person_evidence_button; GtkWidget *person_evidence_summary_label; + GtkWidget *entity_evidence_box; char *selected_entity_identifier; char *selected_entity_type; @@ -846,6 +847,8 @@ EntityDetailsPanel *entity_details_panel_new(void) GTK_WIDGET(details_panel->person_name_entry)); gtk_box_append(GTK_BOX(details_panel->person_role_box), details_panel->person_name_button); + details_panel->entity_evidence_box = gtk_box_new( + GTK_ORIENTATION_VERTICAL, 6); details_panel->person_evidence_button = gtk_button_new_with_label( "Gérer les pièces jointes"); details_panel->person_evidence_summary_label = gtk_label_new( @@ -856,13 +859,14 @@ EntityDetailsPanel *entity_details_panel_new(void) details_panel->person_evidence_summary_label), TRUE); gtk_label_set_selectable(GTK_LABEL( details_panel->person_evidence_summary_label), TRUE); - gtk_box_append(GTK_BOX(details_panel->person_role_box), - gtk_label_new("Preuves associées à cette personne")); - gtk_box_append(GTK_BOX(details_panel->person_role_box), + gtk_box_append(GTK_BOX(details_panel->entity_evidence_box), + gtk_label_new("Preuves associées à cette entité")); + gtk_box_append(GTK_BOX(details_panel->entity_evidence_box), details_panel->person_evidence_summary_label); - gtk_box_append(GTK_BOX(details_panel->person_role_box), + gtk_box_append(GTK_BOX(details_panel->entity_evidence_box), details_panel->person_evidence_button); gtk_box_append(GTK_BOX(details_box), details_panel->person_role_box); + gtk_box_append(GTK_BOX(details_box), details_panel->entity_evidence_box); g_signal_connect(details_panel->person_role_dropdown, "notify::selected", G_CALLBACK(entity_details_panel_on_person_role_changed), details_panel); g_signal_connect(details_panel->person_confidence_button, "clicked", diff --git a/src/widgets/investigation_graph_view.c b/src/widgets/investigation_graph_view.c index b752d09..7478ae8 100644 --- a/src/widgets/investigation_graph_view.c +++ b/src/widgets/investigation_graph_view.c @@ -120,6 +120,8 @@ struct InvestigationGraphView InvestigationGraphViewNodeMovedCallback node_moved_callback; gpointer node_moved_user_data; + InvestigationGraphViewExtractionDropCallback extraction_drop_callback; + gpointer extraction_drop_user_data; double node_drag_pointer_offset_x; double node_drag_pointer_offset_y; @@ -128,8 +130,46 @@ struct InvestigationGraphView gboolean dragging; gboolean node_dragging; gboolean click_suppressed; + GtkDropTarget *extraction_drop_target; }; +static gboolean investigation_graph_view_screen_to_logical( + const InvestigationGraphView *graph_view, double screen_x, double screen_y, + double *logical_x, double *logical_y); +static InvestigationGraphNodeLayout *investigation_graph_view_find_node_at( + InvestigationGraphView *graph_view, double logical_x, double logical_y); + +/** @brief Relaie un chemin d'extraction et l'éventuelle entité visée. */ +static gboolean investigation_graph_view_on_extraction_drop( + GtkDropTarget *target, const GValue *value, double x, double y, + gpointer user_data) +{ + InvestigationGraphView *graph_view = user_data; + InvestigationGraphNodeLayout *node_layout = NULL; + const char *path = NULL; + const char *entity_identifier = NULL; + double logical_x = 0.0; + double logical_y = 0.0; + (void) target; + if (graph_view == NULL || value == NULL || + !G_VALUE_HOLDS_STRING(value)) return FALSE; + path = g_value_get_string(value); + if (path == NULL || path[0] == '\0') return FALSE; + if (investigation_graph_view_screen_to_logical(graph_view, x, y, + &logical_x, &logical_y)) + { + node_layout = investigation_graph_view_find_node_at(graph_view, + logical_x, logical_y); + if (node_layout != NULL && node_layout->entity_record != NULL) + entity_identifier = entity_record_get_identifier( + node_layout->entity_record); + } + if (graph_view->extraction_drop_callback == NULL) return FALSE; + graph_view->extraction_drop_callback(path, entity_identifier, + graph_view->extraction_drop_user_data); + return TRUE; +} + static const char *investigation_graph_view_get_node_identifier( const InvestigationGraphNodeLayout *node_layout ); @@ -3948,6 +3988,13 @@ InvestigationGraphView *investigation_graph_view_new(void) TRUE ); + graph_view->extraction_drop_target = GTK_DROP_TARGET( + gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY)); + g_signal_connect(graph_view->extraction_drop_target, "drop", + G_CALLBACK(investigation_graph_view_on_extraction_drop), graph_view); + gtk_widget_add_controller(graph_view->drawing_area, + GTK_EVENT_CONTROLLER(graph_view->extraction_drop_target)); + gtk_drawing_area_set_content_width( GTK_DRAWING_AREA( graph_view->drawing_area @@ -4414,6 +4461,16 @@ void investigation_graph_view_set_node_moved_callback( user_data; } +void investigation_graph_view_set_extraction_drop_callback( + InvestigationGraphView *graph_view, + InvestigationGraphViewExtractionDropCallback callback, + gpointer user_data) +{ + if (graph_view == NULL) return; + graph_view->extraction_drop_callback = callback; + graph_view->extraction_drop_user_data = user_data; +} + const EntityRecord *investigation_graph_view_get_selected_entity( const InvestigationGraphView *graph_view ) diff --git a/src/widgets/investigation_tree_view.c b/src/widgets/investigation_tree_view.c index f3b79e2..587f4a2 100644 --- a/src/widgets/investigation_tree_view.c +++ b/src/widgets/investigation_tree_view.c @@ -13,6 +13,38 @@ #include #include +/** @brief Prépare le chemin du fichier pour un glisser-déposer. */ +static GdkContentProvider *investigation_tree_view_on_drag_prepare( + GtkDragSource *source, double x, double y, gpointer user_data) +{ + const char *path = g_object_get_data(G_OBJECT(source), + "investigation-node-path"); + (void) x; (void) y; (void) user_data; + if (path == NULL || path[0] == '\0') return NULL; + return gdk_content_provider_new_typed(G_TYPE_STRING, path); +} +/** @brief Vérifie qu'un nœud est un fichier `.txt` sous `Extractions`. */ +static gboolean investigation_tree_view_node_is_extraction( + const InvestigationNode *node) +{ + const InvestigationNode *ancestor = NULL; + const char *name = NULL; + + if (node == NULL || + investigation_node_get_type(node) != INVESTIGATION_NODE_FILE) + return FALSE; + name = investigation_node_get_name(node); + if (name == NULL || !g_str_has_suffix(name, ".txt")) return FALSE; + ancestor = investigation_node_get_parent(node); + while (ancestor != NULL) + { + if (g_strcmp0(investigation_node_get_name(ancestor), + "Extractions") == 0) return TRUE; + ancestor = investigation_node_get_parent(ancestor); + } + return FALSE; +} + /* * InvestigationTreeItem * --------------------- @@ -248,6 +280,7 @@ static void investigation_tree_view_factory_setup( GtkWidget *row_box = NULL; GtkWidget *image = NULL; GtkWidget *label = NULL; + GtkDragSource *drag_source = NULL; (void)factory; (void)user_data; @@ -292,6 +325,14 @@ static void investigation_tree_view_factory_setup( row_box ); + drag_source = gtk_drag_source_new(); + gtk_drag_source_set_actions(drag_source, GDK_ACTION_COPY); + g_signal_connect(drag_source, "prepare", + G_CALLBACK(investigation_tree_view_on_drag_prepare), NULL); + g_object_set_data(G_OBJECT(row_box), "investigation-drag-source", + drag_source); + gtk_widget_add_controller(row_box, GTK_EVENT_CONTROLLER(drag_source)); + gtk_list_item_set_child( list_item, expander @@ -406,6 +447,7 @@ static void investigation_tree_view_factory_bind( GtkWidget *label = NULL; const char *node_name = NULL; + GtkDragSource *drag_source = NULL; (void)factory; (void)user_data; @@ -455,6 +497,13 @@ static void investigation_tree_view_factory_bind( return; } + drag_source = GTK_DRAG_SOURCE(g_object_get_data(G_OBJECT(row_box), + "investigation-drag-source")); + if (drag_source != NULL) + g_object_set_data_full(G_OBJECT(drag_source), "investigation-node-path", + investigation_tree_view_node_is_extraction(node) + ? g_strdup(investigation_node_get_path(node)) : NULL, g_free); + image = gtk_widget_get_first_child( row_box ); diff --git a/src/widgets/workspace.c b/src/widgets/workspace.c index f4da8d7..39a982a 100644 --- a/src/widgets/workspace.c +++ b/src/widgets/workspace.c @@ -129,6 +129,9 @@ struct Workspace gpointer graph_node_moved_user_data; + WorkspaceExtractionDropCallback extraction_drop_callback; + gpointer extraction_drop_user_data; + WorkspaceResetGraphLayoutCallback reset_graph_layout_callback; @@ -960,6 +963,15 @@ static void workspace_on_person_evidence_requested(const char *identifier, workspace->person_evidence_user_data); } +static void workspace_on_extraction_dropped(const char *file_path, + const char *target_entity_identifier, gpointer user_data) +{ + Workspace *workspace = user_data; + if (workspace != NULL && workspace->extraction_drop_callback != NULL) + workspace->extraction_drop_callback(file_path, target_entity_identifier, + workspace->extraction_drop_user_data); +} + /** * @brief Désélectionne le nœud lorsque le volet est fermé manuellement. */ @@ -1939,6 +1951,8 @@ Workspace *workspace_new(void) workspace_on_graph_node_moved, workspace ); + investigation_graph_view_set_extraction_drop_callback( + workspace->graph_view, workspace_on_extraction_dropped, workspace); entity_details_panel_set_close_callback( workspace->entity_details_panel, @@ -3406,6 +3420,16 @@ void workspace_set_graph_node_moved_callback( user_data; } +void workspace_set_extraction_drop_callback( + Workspace *workspace, + WorkspaceExtractionDropCallback callback, + gpointer user_data) +{ + if (workspace == NULL) return; + workspace->extraction_drop_callback = callback; + workspace->extraction_drop_user_data = user_data; +} + void workspace_set_reset_graph_layout_callback( Workspace *workspace, WorkspaceResetGraphLayoutCallback callback, diff --git a/tests/test_extraction_drop_service.c b/tests/test_extraction_drop_service.c new file mode 100644 index 0000000..b9be5df --- /dev/null +++ b/tests/test_extraction_drop_service.c @@ -0,0 +1,183 @@ +#include "core/extraction_drop_service.h" +#include "dao/entity_dao.h" +#include "dao/evidence_dao.h" +#include "dao/evidence_entity_dao.h" +#include "database/database.h" +#include "models/entity_record.h" + +#include +#include + +typedef struct +{ + char *root; + char *extractions; + char *file_path; + char *database_path; + Database *database; +} ExtractionDropFixture; + +static void fixture_setup(ExtractionDropFixture *fixture, gconstpointer data) +{ + GError *error = NULL; + (void) data; + fixture->root = g_dir_make_tmp("labfy-extraction-drop-XXXXXX", &error); + g_assert_no_error(error); + fixture->extractions = g_build_filename(fixture->root, + "02_Preuves_Traitees", "Extractions", "OSINT", NULL); + g_assert_cmpint(g_mkdir_with_parents(fixture->extractions, 0700), ==, 0); + fixture->file_path = g_build_filename(fixture->extractions, + "resultat.txt", NULL); + g_assert_true(g_file_set_contents(fixture->file_path, + "Cible : exemple.test\nAction : test\n\nRésultat public", -1, &error)); + g_assert_no_error(error); + fixture->database_path = g_build_filename(fixture->root, + "Enquete.sqlite", NULL); + g_assert_true(database_initialize(fixture->database_path, + "Extraction", fixture->root)); + fixture->database = database_open(fixture->database_path); + g_assert_nonnull(fixture->database); + g_assert_true(database_migrate_to_latest(fixture->database)); +} + +static void fixture_teardown(ExtractionDropFixture *fixture, + gconstpointer data) +{ + (void) data; + database_close(fixture->database); + g_remove(fixture->file_path); + g_remove(fixture->database_path); + g_rmdir(fixture->extractions); + { + char *directory = g_path_get_dirname(fixture->extractions); + g_rmdir(directory); + g_free(directory); + } + { + char *directory = g_build_filename(fixture->root, + "02_Preuves_Traitees", NULL); + g_rmdir(directory); + g_free(directory); + } + g_rmdir(fixture->root); + g_free(fixture->database_path); + g_free(fixture->file_path); + g_free(fixture->extractions); + g_free(fixture->root); +} + +static char *insert_entity(Database *database) +{ + EntityDao *dao = NULL; + EntityRecord *record = NULL; + char *identifier = g_uuid_string_random(); + GError *error = NULL; + dao = entity_dao_new(database, &error); + record = entity_record_new(identifier, "other", "Destination", + "Destination", NULL, 50, "2026-07-23T12:00:00Z", + "2026-07-23T12:00:00Z", ENTITY_STATUS_ACTIVE, &error); + g_assert_no_error(error); + g_assert_true(entity_dao_insert(dao, record, &error)); + g_assert_no_error(error); + entity_record_free(record); + entity_dao_free(dao); + return identifier; +} + +static void test_prepare_rejects_outside(ExtractionDropFixture *fixture, + gconstpointer data) +{ + ExtractionDropInfo *info = NULL; + char *outside = g_build_filename(fixture->root, "hors.txt", NULL); + GError *error = NULL; + (void) data; + g_assert_true(g_file_set_contents(outside, "hors enquête", -1, &error)); + g_assert_no_error(error); + info = extraction_drop_service_prepare(fixture->root, outside, &error); + g_assert_null(info); + g_assert_error(error, EXTRACTION_DROP_SERVICE_ERROR, + EXTRACTION_DROP_SERVICE_ERROR_OUTSIDE_INVESTIGATION); + g_clear_error(&error); + g_remove(outside); + g_free(outside); +} + +static void test_attach_persists_and_is_idempotent( + ExtractionDropFixture *fixture, gconstpointer data) +{ + ExtractionDropInfo *info = NULL; + EvidenceDao *evidence_dao = NULL; + EvidenceEntityDao *link_dao = NULL; + GPtrArray *records = NULL; + char *entity_identifier = NULL; + gboolean exists = FALSE; + GError *error = NULL; + (void) data; + + info = extraction_drop_service_prepare(fixture->root, + fixture->file_path, &error); + g_assert_no_error(error); + g_assert_nonnull(info); + g_assert_cmpstr(extraction_drop_info_get_source(info), ==, + "Cible : exemple.test"); + entity_identifier = insert_entity(fixture->database); + g_assert_true(extraction_drop_service_attach(fixture->database, info, + entity_identifier, &error)); + g_assert_no_error(error); + g_assert_true(extraction_drop_service_attach(fixture->database, info, + entity_identifier, &error)); + g_assert_no_error(error); + evidence_dao = evidence_dao_new(fixture->database, &error); + records = evidence_dao_list_all(evidence_dao, &error); + g_assert_no_error(error); + g_assert_cmpuint(records->len, ==, 1U); + link_dao = evidence_entity_dao_new(fixture->database, &error); + g_assert_true(evidence_entity_dao_exists(link_dao, + evidence_record_get_identifier(g_ptr_array_index(records, 0U)), + entity_identifier, &exists, &error)); + g_assert_no_error(error); + g_assert_true(exists); + evidence_entity_dao_free(link_dao); + g_ptr_array_unref(records); + evidence_dao_free(evidence_dao); + g_free(entity_identifier); + extraction_drop_info_free(info); +} + +static void test_create_entity_persists(ExtractionDropFixture *fixture, + gconstpointer data) +{ + ExtractionDropInfo *info = NULL; + EntityDao *entity_dao = NULL; + EntityRecord *entity = NULL; + char *identifier = NULL; + GError *error = NULL; + (void) data; + info = extraction_drop_service_prepare(fixture->root, + fixture->file_path, &error); + g_assert_true(extraction_drop_service_create_entity(fixture->database, + info, &identifier, &error)); + g_assert_no_error(error); + g_assert_true(g_uuid_string_is_valid(identifier)); + entity_dao = entity_dao_new(fixture->database, &error); + entity = entity_dao_find_by_identifier(entity_dao, identifier, &error); + g_assert_no_error(error); + g_assert_nonnull(entity); + g_assert_cmpstr(entity_record_get_type_identifier(entity), ==, "other"); + entity_record_free(entity); + entity_dao_free(entity_dao); + g_free(identifier); + extraction_drop_info_free(info); +} + +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add("/extraction-drop/outside", ExtractionDropFixture, NULL, + fixture_setup, test_prepare_rejects_outside, fixture_teardown); + g_test_add("/extraction-drop/attach", ExtractionDropFixture, NULL, + fixture_setup, test_attach_persists_and_is_idempotent, fixture_teardown); + g_test_add("/extraction-drop/create", ExtractionDropFixture, NULL, + fixture_setup, test_create_entity_persists, fixture_teardown); + return g_test_run(); +}