feat(graph): integrate dropped extractions

This commit is contained in:
grayTerminal-sh 2026-07-23 13:58:23 +02:00
parent 8adfc3f106
commit 33a4a8be10
16 changed files with 964 additions and 51 deletions

View file

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

View file

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

View file

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

View file

@ -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 lenquê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. Lutilisateur choisit explicitement :
- créer une nouvelle entité à partir de lextraction ;
- rattacher lextraction à une entité existante ;
- annuler.
5. Lassociation 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 lenquê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 dacceptation
- Un fichier `.txt` dOSINT est déplaçable depuis la Sidebar.
- Le dépôt sur le graphe ouvre le dialogue de choix.
- Lentité ou lassociation créée survit à la réouverture de lenquête.
- Les dépôts annulés ne modifient ni la base ni le graphe.
- Tests unitaires des chemins invalides, du choix daction et de la
persistance.
## Vérification
```sh
make -j2
make test
```

View file

@ -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 <glib.h>
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

View file

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

View file

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

View file

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

View file

@ -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 <gtk/gtk.h>
@ -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,

View file

@ -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 <string.h>
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;
}

View file

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

View file

@ -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",

View file

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

View file

@ -13,6 +13,38 @@
#include <gtk/gtk.h>
#include <string.h>
/** @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
);

View file

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

View file

@ -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 <glib.h>
#include <glib/gstdio.h>
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();
}