From 8d5a5910dbb4e6378958c2dea513d51fc7133eae Mon Sep 17 00:00:00 2001 From: grayTerminal-sh Date: Wed, 22 Jul 2026 18:15:48 +0200 Subject: [PATCH] feat(graph): add interactive relation details and synchronized labels --- include/dao/relation_dao.h | 11 ++ include/views/create_relation_dialog.h | 16 +++ include/views/main_window.h | 19 ++++ include/widgets/workspace.h | 13 +++ src/core/application.c | 146 +++++++++++++++++++++++++ src/dao/relation_dao.c | 65 +++++++++++ src/views/create_relation_dialog.c | 73 ++++++++++++- src/views/main_window.c | 42 +++++++ src/widgets/entity_details_panel.c | 3 + src/widgets/investigation_graph_view.c | 126 +++++++++++++++++++++ src/widgets/workspace.c | 96 +++++++++++++++- tests/test_relation_dao.c | 42 +++++++ 12 files changed, 642 insertions(+), 10 deletions(-) diff --git a/include/dao/relation_dao.h b/include/dao/relation_dao.h index cac9eee..a805521 100644 --- a/include/dao/relation_dao.h +++ b/include/dao/relation_dao.h @@ -93,6 +93,17 @@ gboolean relation_dao_insert( GError **error ); +/** + * @brief Met à jour les champs modifiables d'une relation existante. + * + * L'identifiant, les extrémités et la date de création sont conservés. + */ +gboolean relation_dao_update( + RelationDao *relation_dao, + const RelationRecord *relation_record, + GError **error +); + /** * @brief Recherche une relation par son UUID. * diff --git a/include/views/create_relation_dialog.h b/include/views/create_relation_dialog.h index 909f26b..3b02678 100644 --- a/include/views/create_relation_dialog.h +++ b/include/views/create_relation_dialog.h @@ -14,6 +14,7 @@ G_BEGIN_DECLS * @brief Résultat opaque produit par le dialogue. */ typedef struct CreateRelationDialogResult CreateRelationDialogResult; +typedef struct RelationRecord RelationRecord; /** * @brief Codes d'erreur produits par CreateRelationDialog. @@ -82,6 +83,21 @@ gboolean create_relation_dialog_present( GError **error ); +/** + * @brief Présente le dialogue prérempli pour modifier une relation. + * + * La source et la cible sont conservées ; les champs métier sont modifiables. + * Le dialogue ne réalise aucune écriture SQLite. + */ +gboolean edit_relation_dialog_present( + GtkWindow *parent, + const RelationRecord *relation_record, + const GPtrArray *entities, + CreateRelationDialogCallback callback, + gpointer user_data, + GError **error +); + /** * @brief Valide les valeurs obligatoires d'une relation. * diff --git a/include/views/main_window.h b/include/views/main_window.h index dc79869..a30abf8 100644 --- a/include/views/main_window.h +++ b/include/views/main_window.h @@ -137,6 +137,12 @@ typedef void (*MainWindowAddRelationCallback)( gpointer user_data ); +/** @brief Callback appelé pour modifier une relation. */ +typedef void (*MainWindowEditRelationCallback)( + const char *relation_identifier, + gpointer user_data +); + /** * @brief Callback appelé lors du déclenchement d'une action OSINT. * @@ -227,6 +233,13 @@ void main_window_set_add_relation_callback( gpointer user_data ); +/** @brief Définit le callback de modification d'une relation. */ +void main_window_set_edit_relation_callback( + MainWindow *main_window, + MainWindowEditRelationCallback callback, + gpointer user_data +); + /** * @brief Définit le callback de déclenchement des actions OSINT. * @@ -419,6 +432,12 @@ void main_window_set_graph( const InvestigationGraphLayout *graph_layout ); +/** @brief Sélectionne une relation dans le graphe affiché. */ +gboolean main_window_select_graph_relation( + MainWindow *main_window, + const char *relation_identifier +); + /** * @brief Affiche une erreur de chargement du graphe. * diff --git a/include/widgets/workspace.h b/include/widgets/workspace.h index 46111da..53df859 100644 --- a/include/widgets/workspace.h +++ b/include/widgets/workspace.h @@ -78,6 +78,12 @@ typedef void (*WorkspaceAddRelationCallback)( gpointer user_data ); +/** @brief Callback appelé pour modifier la relation sélectionnée. */ +typedef void (*WorkspaceEditRelationCallback)( + const char *relation_identifier, + gpointer user_data +); + /** * @brief Callback appelé lors du déclenchement d'une action OSINT. * @@ -288,6 +294,13 @@ void workspace_set_add_relation_callback( gpointer user_data ); +/** @brief Définit le callback de modification d'une relation. */ +void workspace_set_edit_relation_callback( + Workspace *workspace, + WorkspaceEditRelationCallback callback, + gpointer user_data +); + /** * @brief Affiche l'état de chargement du graphe. * diff --git a/src/core/application.c b/src/core/application.c index 2458972..4a67291 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -40,6 +40,7 @@ #include "views/osint_execution_history_dialog.h" #include "dao/evidence_dao.h" #include "dao/entity_dao.h" +#include "dao/relation_dao.h" #include "dao/osint_execution_dao.h" #include "models/evidence_type.h" #include "widgets/evidence_list_model.h" @@ -52,6 +53,7 @@ #include "core/eml_processing.h" #include "core/eml_integration.h" #include "database/database.h" +#include "database/transaction.h" #include "views/create_social_account_dialog.h" #include "views/create_person_dialog.h" #include "views/eml_analysis_dialog.h" @@ -123,6 +125,7 @@ struct Application guint64 graph_load_generation; char *selected_evidence_identifier; + char *pending_relation_selection_identifier; }; /** @@ -737,6 +740,14 @@ static void application_on_graph_loaded( application->graph_layout ); + if (application->pending_relation_selection_identifier != NULL) + { + main_window_select_graph_relation(application->main_window, + application->pending_relation_selection_identifier); + g_clear_pointer(&application->pending_relation_selection_identifier, + g_free); + } + status_message = g_strdup_printf( "Graphe chargé : %" G_GUINT64_FORMAT @@ -4917,6 +4928,136 @@ cleanup: /** * @brief Ouvre le dialogue de création depuis la fiche d'une entité. */ +/** @brief Contexte conservé pendant l'édition d'une relation. */ +typedef struct +{ + Application *application; + char *relation_identifier; +} ApplicationEditRelationContext; + +/** @brief Libère le contexte d'édition d'une relation. */ +static void application_edit_relation_context_free( + ApplicationEditRelationContext *context) +{ + if (context == NULL) return; + g_free(context->relation_identifier); + g_free(context); +} + +/** @brief Persiste les valeurs validées du dialogue d'édition. */ +static void application_on_edit_relation_completed( + CreateRelationDialogResult *result, gpointer user_data) +{ + ApplicationEditRelationContext *context = user_data; + Application *application = context != NULL ? context->application : NULL; + Database *database = NULL; + RelationDao *relation_dao = NULL; + RelationRecord *existing = NULL; + RelationRecord *updated = NULL; + const InvestigationProject *project = NULL; + GDateTime *now = NULL; + char *timestamp = NULL; + GError *error = NULL; + gboolean transaction_active = FALSE; + + if (result == NULL || application == NULL || application->session == NULL) + goto cleanup; + database = investigation_session_get_database(application->session); + project = investigation_session_get_project(application->session); + relation_dao = relation_dao_new(database, &error); + if (relation_dao == NULL) goto failure; + existing = relation_dao_find_by_identifier(relation_dao, + context->relation_identifier, &error); + if (existing == NULL) goto failure; + now = g_date_time_new_now_utc(); + timestamp = now != NULL ? g_date_time_format(now, + "%Y-%m-%dT%H:%M:%SZ") : NULL; + updated = relation_record_new( + relation_record_get_identifier(existing), + relation_record_get_source_entity_identifier(existing), + relation_record_get_target_entity_identifier(existing), + create_relation_dialog_result_get_relation_type(result), + create_relation_dialog_result_get_label(result), + create_relation_dialog_result_get_justification(result), + create_relation_dialog_result_get_confidence(result), + relation_record_get_created_at(existing), timestamp, + relation_record_get_status(existing), &error); + if (updated == NULL || !database_transaction_begin(database)) goto failure; + transaction_active = TRUE; + if (!relation_dao_update(relation_dao, updated, &error) || + !database_transaction_commit(database)) + goto failure; + transaction_active = FALSE; + main_window_set_status(application->main_window, + "Relation modifiée. Actualisation du graphe…"); + g_free(application->pending_relation_selection_identifier); + application->pending_relation_selection_identifier = g_strdup( + context->relation_identifier); + application_start_graph_loading(application, + investigation_project_get_database_path(project)); + goto cleanup; + +failure: + if (transaction_active) database_transaction_rollback(database); + application_present_error(application, "Modification impossible", + error != NULL ? error->message : + "La relation n'a pas pu être modifiée."); +cleanup: + g_clear_error(&error); + g_free(timestamp); + if (now != NULL) g_date_time_unref(now); + relation_record_free(updated); + relation_record_free(existing); + relation_dao_free(relation_dao); + create_relation_dialog_result_free(result); + application_edit_relation_context_free(context); +} + +/** @brief Ouvre le formulaire prérempli de la relation sélectionnée. */ +static void application_on_edit_relation_requested( + const char *relation_identifier, gpointer user_data) +{ + Application *application = user_data; + Database *database = NULL; + RelationDao *relation_dao = NULL; + EntityDao *entity_dao = NULL; + RelationRecord *relation = NULL; + GPtrArray *entities = NULL; + ApplicationEditRelationContext *context = NULL; + GError *error = NULL; + + if (application == NULL || application->session == NULL || + relation_identifier == NULL) return; + database = investigation_session_get_database(application->session); + relation_dao = relation_dao_new(database, &error); + entity_dao = entity_dao_new(database, &error); + if (relation_dao != NULL) + relation = relation_dao_find_by_identifier(relation_dao, + relation_identifier, &error); + if (entity_dao != NULL) entities = entity_dao_list_all(entity_dao, &error); + if (relation == NULL || entities == NULL) goto failure; + context = g_new0(ApplicationEditRelationContext, 1); + context->application = application; + context->relation_identifier = g_strdup(relation_identifier); + if (context->relation_identifier == NULL || !edit_relation_dialog_present( + main_window_get_window(application->main_window), relation, entities, + application_on_edit_relation_completed, context, &error)) + goto failure; + context = NULL; + goto cleanup; +failure: + application_present_error(application, "Modification impossible", + error != NULL ? error->message : + "Le formulaire de modification n'a pas pu être ouvert."); +cleanup: + application_edit_relation_context_free(context); + g_clear_error(&error); + g_clear_pointer(&entities, g_ptr_array_unref); + relation_record_free(relation); + entity_dao_free(entity_dao); + relation_dao_free(relation_dao); +} + static void application_on_add_relation_requested( const char *source_entity_identifier, gpointer user_data @@ -6333,6 +6474,9 @@ static void application_on_activate( application ); + main_window_set_edit_relation_callback(application->main_window, + application_on_edit_relation_requested, application); + main_window_set_osint_action_callback( application->main_window, application_on_osint_action_requested, @@ -6606,6 +6750,8 @@ void application_free( &application->selected_evidence_identifier, g_free ); + g_clear_pointer(&application->pending_relation_selection_identifier, + g_free); g_free( application diff --git a/src/dao/relation_dao.c b/src/dao/relation_dao.c index e180a4e..dd0be54 100644 --- a/src/dao/relation_dao.c +++ b/src/dao/relation_dao.c @@ -52,6 +52,12 @@ static const char *const relation_dao_insert_sql = " ?" ");"; +/** @brief Requête de modification des attributs d'une relation. */ +static const char *const relation_dao_update_sql = + "UPDATE relations SET " + "type_relation = ?, label = ?, justification = ?, confiance = ?, " + "updated_at = ?, status = ? WHERE id = ?;"; + /** * @brief Requête de recherche par UUID. */ @@ -1298,6 +1304,65 @@ cleanup: return success; } +gboolean relation_dao_update(RelationDao *relation_dao, + const RelationRecord *relation_record, GError **error) +{ + DatabaseStatement *statement = NULL; + const char *status_text = NULL; + gboolean success = FALSE; + + g_return_val_if_fail(error == NULL || *error == NULL, FALSE); + if (relation_dao == NULL || relation_record == NULL) + { + relation_dao_set_error_literal(error, + RELATION_DAO_ERROR_INVALID_ARGUMENT, + "La relation à modifier est invalide."); + return FALSE; + } + status_text = relation_dao_status_to_text( + relation_record_get_status(relation_record)); + statement = database_statement_prepare(relation_dao->database, + relation_dao_update_sql); + if (statement == NULL) + { + relation_dao_set_database_error(relation_dao, error, + RELATION_DAO_ERROR_PREPARE, + "Impossible de préparer la modification de la relation"); + return FALSE; + } + if (!database_statement_bind_text(statement, 1, + relation_record_get_relation_type(relation_record)) || + !relation_dao_bind_optional_text(statement, 2, + relation_record_get_label(relation_record)) || + !relation_dao_bind_optional_text(statement, 3, + relation_record_get_justification(relation_record)) || + !database_statement_bind_int64(statement, 4, + relation_record_get_confidence(relation_record)) || + !database_statement_bind_text(statement, 5, + relation_record_get_updated_at(relation_record)) || + status_text == NULL || + !database_statement_bind_text(statement, 6, status_text) || + !database_statement_bind_text(statement, 7, + relation_record_get_identifier(relation_record))) + { + relation_dao_set_database_error(relation_dao, error, + RELATION_DAO_ERROR_BIND, + "Impossible de lier la modification de la relation"); + goto cleanup; + } + if (database_statement_step(statement) != DATABASE_STATEMENT_STEP_DONE) + { + relation_dao_set_database_error(relation_dao, error, + RELATION_DAO_ERROR_EXECUTE, + "Impossible de modifier la relation"); + goto cleanup; + } + success = TRUE; +cleanup: + database_statement_finalize(statement); + return success; +} + RelationRecord *relation_dao_find_by_identifier( RelationDao *relation_dao, const char *identifier, diff --git a/src/views/create_relation_dialog.c b/src/views/create_relation_dialog.c index 795f630..6f3c725 100644 --- a/src/views/create_relation_dialog.c +++ b/src/views/create_relation_dialog.c @@ -6,6 +6,7 @@ #include "views/create_relation_dialog.h" #include "models/entity_record.h" +#include "models/relation_record.h" #include @@ -683,9 +684,10 @@ cleanup: return valid; } -gboolean create_relation_dialog_present( +static gboolean create_relation_dialog_present_internal( GtkWindow *parent, const char *source_entity_identifier, + const RelationRecord *existing_relation, const GPtrArray *entities, CreateRelationDialogCallback callback, gpointer user_data, @@ -746,6 +748,9 @@ gboolean create_relation_dialog_present( guint entity_index = 0; + guint selected_target_index = + 0; + g_return_val_if_fail( error == NULL || *error == NULL, FALSE @@ -900,6 +905,13 @@ gboolean create_relation_dialog_present( entity_identifier ); + if (existing_relation != NULL && g_strcmp0(entity_identifier, + relation_record_get_target_entity_identifier( + existing_relation)) == 0) + { + selected_target_index = state->target_identifiers->len; + } + if (display_text == NULL || identifier_copy == NULL) { @@ -971,7 +983,9 @@ gboolean create_relation_dialog_present( gtk_window_set_title( state->window, - "Ajouter une relation" + existing_relation != NULL + ? "Modifier la relation" + : "Ajouter une relation" ); gtk_window_set_transient_for( @@ -1030,7 +1044,9 @@ gboolean create_relation_dialog_present( GTK_LABEL( title_label ), - "Créer une relation entre deux entités" + existing_relation != NULL + ? "Modifier la relation" + : "Créer une relation entre deux entités" ); gtk_label_set_xalign( @@ -1179,9 +1195,12 @@ gboolean create_relation_dialog_present( gtk_drop_down_set_selected( state->target_drop_down, - 0 + selected_target_index ); + gtk_widget_set_sensitive(GTK_WIDGET(state->target_drop_down), + existing_relation == NULL); + gtk_widget_set_hexpand( GTK_WIDGET( state->target_drop_down @@ -1220,7 +1239,9 @@ gboolean create_relation_dialog_present( gtk_spin_button_set_value( state->confidence_spin_button, - 80.0 + existing_relation != NULL + ? relation_record_get_confidence(existing_relation) + : 80.0 ); state->justification_text_view = @@ -1228,6 +1249,22 @@ gboolean create_relation_dialog_present( gtk_text_view_new() ); + if (existing_relation != NULL) + { + const char *relation_type = relation_record_get_relation_type( + existing_relation); + const char *label = relation_record_get_label(existing_relation); + const char *justification = relation_record_get_justification( + existing_relation); + gtk_editable_set_text(GTK_EDITABLE(state->relation_type_entry), + relation_type != NULL ? relation_type : ""); + gtk_editable_set_text(GTK_EDITABLE(state->label_entry), + label != NULL ? label : ""); + gtk_text_buffer_set_text(gtk_text_view_get_buffer( + state->justification_text_view), + justification != NULL ? justification : "", -1); + } + gtk_text_view_set_wrap_mode( state->justification_text_view, GTK_WRAP_WORD_CHAR @@ -1418,7 +1455,7 @@ gboolean create_relation_dialog_present( create_button = gtk_button_new_with_label( - "Ajouter" + existing_relation != NULL ? "Enregistrer" : "Ajouter" ); gtk_widget_add_css_class( @@ -1543,6 +1580,30 @@ gboolean create_relation_dialog_present( return TRUE; } +gboolean create_relation_dialog_present(GtkWindow *parent, + const char *source_entity_identifier, const GPtrArray *entities, + CreateRelationDialogCallback callback, gpointer user_data, GError **error) +{ + return create_relation_dialog_present_internal(parent, + source_entity_identifier, NULL, entities, callback, user_data, error); +} + +gboolean edit_relation_dialog_present(GtkWindow *parent, + const RelationRecord *relation_record, const GPtrArray *entities, + CreateRelationDialogCallback callback, gpointer user_data, GError **error) +{ + if (relation_record == NULL) + { + g_set_error_literal(error, CREATE_RELATION_DIALOG_ERROR, + CREATE_RELATION_DIALOG_ERROR_INVALID_ARGUMENT, + "La relation à modifier est invalide."); + return FALSE; + } + return create_relation_dialog_present_internal(parent, + relation_record_get_source_entity_identifier(relation_record), + relation_record, entities, callback, user_data, error); +} + void create_relation_dialog_result_free( CreateRelationDialogResult *result ) diff --git a/src/views/main_window.c b/src/views/main_window.c index f2ddb7a..e396a77 100644 --- a/src/views/main_window.c +++ b/src/views/main_window.c @@ -129,6 +129,9 @@ struct MainWindow gpointer add_relation_user_data; + MainWindowEditRelationCallback edit_relation_callback; + gpointer edit_relation_user_data; + MainWindowOsintActionCallback osint_action_callback; @@ -509,6 +512,20 @@ static void main_window_on_add_relation_requested( ); } +/** @brief Relaie la demande de modification d'une relation. */ +static void main_window_on_edit_relation_requested( + const char *relation_identifier, gpointer user_data) +{ + MainWindow *main_window = user_data; + if (main_window == NULL || main_window->edit_relation_callback == NULL || + relation_identifier == NULL) + { + return; + } + main_window->edit_relation_callback(relation_identifier, + main_window->edit_relation_user_data); +} + /** * @brief Relaie la demande de vérification provenant du Workspace. */ @@ -847,6 +864,9 @@ MainWindow *main_window_new( main_window ); + workspace_set_edit_relation_callback(main_window->workspace, + main_window_on_edit_relation_requested, main_window); + workspace_set_osint_action_callback( main_window->workspace, main_window_on_osint_action_requested, @@ -1252,6 +1272,14 @@ void main_window_set_graph( ); } +gboolean main_window_select_graph_relation(MainWindow *main_window, + const char *relation_identifier) +{ + if (main_window == NULL) return FALSE; + return workspace_select_graph_relation(main_window->workspace, + relation_identifier); +} + void main_window_set_graph_error( MainWindow *main_window, const char *message @@ -1614,6 +1642,14 @@ void main_window_set_add_relation_callback( user_data; } +void main_window_set_edit_relation_callback(MainWindow *main_window, + MainWindowEditRelationCallback callback, gpointer user_data) +{ + if (main_window == NULL) return; + main_window->edit_relation_callback = callback; + main_window->edit_relation_user_data = user_data; +} + void main_window_set_quit_callback( MainWindow *main_window, MainWindowQuitCallback callback, @@ -1765,6 +1801,9 @@ void main_window_free( NULL ); + workspace_set_edit_relation_callback(main_window->workspace, + NULL, NULL); + main_window->graph_node_moved_callback = NULL; @@ -1783,6 +1822,9 @@ void main_window_free( main_window->add_relation_user_data = NULL; + main_window->edit_relation_callback = NULL; + main_window->edit_relation_user_data = NULL; + main_window->show_graph_callback = NULL; diff --git a/src/widgets/entity_details_panel.c b/src/widgets/entity_details_panel.c index a6963a8..b18b972 100644 --- a/src/widgets/entity_details_panel.c +++ b/src/widgets/entity_details_panel.c @@ -923,6 +923,7 @@ void entity_details_panel_set_entity( ), TRUE ); + gtk_widget_set_can_target(details_panel->root_revealer, TRUE); g_free( confidence_text @@ -1009,6 +1010,8 @@ void entity_details_panel_clear( ), FALSE ); + /* Le revealer fermé ne doit pas bloquer le canvas sous l'overlay. */ + gtk_widget_set_can_target(details_panel->root_revealer, FALSE); } } diff --git a/src/widgets/investigation_graph_view.c b/src/widgets/investigation_graph_view.c index 999455d..55fa2bb 100644 --- a/src/widgets/investigation_graph_view.c +++ b/src/widgets/investigation_graph_view.c @@ -61,6 +61,9 @@ typedef struct double width; double height; + + double relation_offset_x; + double relation_offset_y; } InvestigationGraphNodeLayout; /** @@ -130,6 +133,58 @@ struct InvestigationGraphView static const char *investigation_graph_view_get_node_identifier( const InvestigationGraphNodeLayout *node_layout ); +static double investigation_graph_view_get_node_center_x( + const InvestigationGraphNodeLayout *node_layout +); +static double investigation_graph_view_get_node_center_y( + const InvestigationGraphNodeLayout *node_layout +); + +/** @brief Replace les libellés de relation entre leurs extrémités. */ +static void investigation_graph_view_sync_relation_layouts( + InvestigationGraphView *graph_view +) +{ + if (graph_view == NULL || graph_view->node_layouts == NULL || + graph_view->node_layouts_by_identifier == NULL) + { + return; + } + + for (guint i = 0; i < graph_view->node_layouts->len; i++) + { + InvestigationGraphNodeLayout *relation_layout = + g_ptr_array_index(graph_view->node_layouts, i); + const RelationRecord *relation_record = NULL; + InvestigationGraphNodeLayout *source_layout = NULL; + InvestigationGraphNodeLayout *target_layout = NULL; + + if (relation_layout == NULL || relation_layout->kind != + INVESTIGATION_GRAPH_NODE_KIND_RELATION) + { + continue; + } + relation_record = relation_layout->relation_record; + source_layout = g_hash_table_lookup( + graph_view->node_layouts_by_identifier, + relation_record_get_source_entity_identifier(relation_record)); + target_layout = g_hash_table_lookup( + graph_view->node_layouts_by_identifier, + relation_record_get_target_entity_identifier(relation_record)); + if (source_layout == NULL || target_layout == NULL) + { + continue; + } + relation_layout->x = + (investigation_graph_view_get_node_center_x(source_layout) + + investigation_graph_view_get_node_center_x(target_layout)) / 2.0 - + relation_layout->width / 2.0 + relation_layout->relation_offset_x; + relation_layout->y = + (investigation_graph_view_get_node_center_y(source_layout) + + investigation_graph_view_get_node_center_y(target_layout)) / 2.0 - + relation_layout->height / 2.0 + relation_layout->relation_offset_y; + } +} /** * @brief Borne un facteur de zoom aux limites autorisées. @@ -577,6 +632,8 @@ investigation_graph_view_find_node_at( return NULL; } + investigation_graph_view_sync_relation_layouts(graph_view); + layout_position = graph_view->node_layouts->len; @@ -611,6 +668,63 @@ investigation_graph_view_find_node_at( } } + /* Rend également l'arête sélectionnable, avec une tolérance visuelle. */ + for (layout_position = graph_view->node_layouts->len; + layout_position > 0; layout_position--) + { + InvestigationGraphNodeLayout *relation_layout = + g_ptr_array_index(graph_view->node_layouts, layout_position - 1U); + const RelationRecord *relation_record = NULL; + InvestigationGraphNodeLayout *source_layout = NULL; + InvestigationGraphNodeLayout *target_layout = NULL; + double source_x = 0.0, source_y = 0.0; + double target_x = 0.0, target_y = 0.0; + double delta_x = 0.0, delta_y = 0.0, length_squared = 0.0; + double projection = 0.0, nearest_x = 0.0, nearest_y = 0.0; + double distance_x = 0.0, distance_y = 0.0; + double tolerance = 7.0 / graph_view->zoom; + + if (relation_layout == NULL || relation_layout->kind != + INVESTIGATION_GRAPH_NODE_KIND_RELATION) + { + continue; + } + relation_record = relation_layout->relation_record; + source_layout = g_hash_table_lookup( + graph_view->node_layouts_by_identifier, + relation_record_get_source_entity_identifier(relation_record)); + target_layout = g_hash_table_lookup( + graph_view->node_layouts_by_identifier, + relation_record_get_target_entity_identifier(relation_record)); + if (source_layout == NULL || target_layout == NULL) + { + continue; + } + source_x = investigation_graph_view_get_node_center_x(source_layout); + source_y = investigation_graph_view_get_node_center_y(source_layout); + target_x = investigation_graph_view_get_node_center_x(target_layout); + target_y = investigation_graph_view_get_node_center_y(target_layout); + delta_x = target_x - source_x; + delta_y = target_y - source_y; + length_squared = delta_x * delta_x + delta_y * delta_y; + if (length_squared <= 0.0) + { + continue; + } + projection = ((logical_x - source_x) * delta_x + + (logical_y - source_y) * delta_y) / length_squared; + projection = CLAMP(projection, 0.0, 1.0); + nearest_x = source_x + projection * delta_x; + nearest_y = source_y + projection * delta_y; + distance_x = logical_x - nearest_x; + distance_y = logical_y - nearest_y; + if (distance_x * distance_x + distance_y * distance_y <= + tolerance * tolerance) + { + return relation_layout; + } + } + return NULL; } @@ -1086,6 +1200,8 @@ static void investigation_graph_view_on_drag_update( logical_y - graph_view->node_drag_pointer_offset_y; + investigation_graph_view_sync_relation_layouts(graph_view); + gtk_widget_queue_draw( graph_view->drawing_area ); @@ -1216,6 +1332,8 @@ static void investigation_graph_view_on_drag_end( graph_view->dragged_node->y = logical_y - graph_view->node_drag_pointer_offset_y; + + investigation_graph_view_sync_relation_layouts(graph_view); } } @@ -2020,6 +2138,9 @@ static gboolean investigation_graph_view_append_relation_layouts( parallel_offset ); + relation_layout->relation_offset_x = + perpendicular_x * parallel_offset; + relation_layout->y = ( (source_center_y + target_center_y) / @@ -2031,6 +2152,9 @@ static gboolean investigation_graph_view_append_relation_layouts( parallel_offset ); + relation_layout->relation_offset_y = + perpendicular_y * parallel_offset; + g_ptr_array_add( node_layouts, relation_layout @@ -3686,6 +3810,8 @@ static void investigation_graph_view_draw( return; } + investigation_graph_view_sync_relation_layouts(graph_view); + /* * Les coordonnées de disposition restent exprimées dans l'espace logique * du graphe. Cairo applique ensuite la navigation de la vue. diff --git a/src/widgets/workspace.c b/src/widgets/workspace.c index c92e6ad..e97f7c7 100644 --- a/src/widgets/workspace.c +++ b/src/widgets/workspace.c @@ -67,6 +67,9 @@ struct Workspace GtkWidget *relation_details_panel; GtkWidget *relation_details_title_label; GtkWidget *relation_details_summary_label; + GtkWidget *edit_relation_button; + GtkWidget *close_relation_details_button; + char *selected_relation_identifier; GtkWidget *node_page; @@ -119,6 +122,9 @@ struct Workspace gpointer add_relation_user_data; + WorkspaceEditRelationCallback edit_relation_callback; + gpointer edit_relation_user_data; + WorkspaceOsintActionCallback osint_action_callback; @@ -573,6 +579,20 @@ static const char *workspace_get_integrity_status_text( } } +/** @brief Convertit le statut d'une relation en texte utilisateur. */ +static const char *workspace_get_relation_status_text(RelationStatus status) +{ + switch (status) + { + case RELATION_STATUS_ACTIVE: return "Active"; + case RELATION_STATUS_ARCHIVED: return "Archivée"; + case RELATION_STATUS_DELETED: return "Supprimée"; + case RELATION_STATUS_DISPUTED: return "Contestée"; + case RELATION_STATUS_UNKNOWN: + default: return "Inconnu"; + } +} + /** * @brief Transmet la sélection du graphe au volet de détails. */ @@ -623,6 +643,7 @@ static void workspace_on_graph_node_selected( &workspace->osint_selection_context, osint_selection_context_free ); + g_clear_pointer(&workspace->selected_relation_identifier, g_free); if (entity_record != NULL) { @@ -640,6 +661,8 @@ static void workspace_on_graph_node_selected( if (workspace->relation_details_panel != NULL) { + gtk_widget_set_can_target(workspace->relation_details_panel, + relation_record != NULL); gtk_widget_set_visible( workspace->relation_details_panel, relation_record != NULL @@ -650,6 +673,8 @@ static void workspace_on_graph_node_selected( workspace->relation_details_title_label != NULL && workspace->relation_details_summary_label != NULL) { + workspace->selected_relation_identifier = g_strdup( + relation_record_get_identifier(relation_record)); const EntityRecord *source_entity = investigation_graph_model_find_entity( workspace->graph_model, @@ -670,11 +695,21 @@ static void workspace_on_graph_node_selected( ); const char *label = relation_record_get_label(relation_record); char *summary = g_strdup_printf( - "%s → %s\n\nType : %s\nConfiance : %d %%\nUUID : %s", - entity_record_get_value(source_entity), - entity_record_get_value(target_entity), + "%s → %s\n\nType : %s\nConfiance : %d %%\nStatut : %s\n" + "Justification : %s\nCréée le : %s\nModifiée le : %s\nUUID : %s", + source_entity != NULL ? entity_record_get_value(source_entity) : + "Source inconnue", + target_entity != NULL ? entity_record_get_value(target_entity) : + "Cible inconnue", relation_record_get_relation_type(relation_record), relation_record_get_confidence(relation_record), + workspace_get_relation_status_text( + relation_record_get_status(relation_record)), + relation_record_get_justification(relation_record) != NULL + ? relation_record_get_justification(relation_record) + : "Non renseignée", + relation_record_get_created_at(relation_record), + relation_record_get_updated_at(relation_record), relation_record_get_identifier(relation_record) ); @@ -695,6 +730,31 @@ static void workspace_on_graph_node_selected( ); } +/** @brief Relaie la demande de modification de la relation affichée. */ +static void workspace_on_edit_relation_clicked(GtkButton *button, + gpointer user_data) +{ + Workspace *workspace = user_data; + (void) button; + if (workspace == NULL || workspace->edit_relation_callback == NULL || + workspace->selected_relation_identifier == NULL) + { + return; + } + workspace->edit_relation_callback(workspace->selected_relation_identifier, + workspace->edit_relation_user_data); +} + +/** @brief Ferme le volet de relation et désélectionne le graphe. */ +static void workspace_on_close_relation_details_clicked(GtkButton *button, + gpointer user_data) +{ + Workspace *workspace = user_data; + (void) button; + if (workspace != NULL) + investigation_graph_view_clear_selection(workspace->graph_view); +} + /** * @brief Relaie la demande d'ajout d'une relation. */ @@ -1493,12 +1553,17 @@ Workspace *workspace_new(void) ); workspace->relation_details_title_label = gtk_label_new(NULL); workspace->relation_details_summary_label = gtk_label_new(NULL); + workspace->edit_relation_button = gtk_button_new_with_label("Modifier"); + workspace->close_relation_details_button = + gtk_button_new_from_icon_name("window-close-symbolic"); if (workspace->graph_view == NULL || workspace->entity_details_panel == NULL || workspace->relation_details_panel == NULL || workspace->relation_details_title_label == NULL || - workspace->relation_details_summary_label == NULL) + workspace->relation_details_summary_label == NULL || + workspace->edit_relation_button == NULL || + workspace->close_relation_details_button == NULL) { workspace_free( workspace @@ -1514,6 +1579,8 @@ Workspace *workspace_new(void) gtk_widget_set_margin_top(workspace->relation_details_panel, 56); gtk_widget_set_margin_start(workspace->relation_details_panel, 12); gtk_widget_set_margin_bottom(workspace->relation_details_panel, 12); + /* `view` fournit le fond opaque du thème, comme le volet d'entité. */ + gtk_widget_add_css_class(workspace->relation_details_panel, "view"); gtk_widget_add_css_class(workspace->relation_details_panel, "card"); gtk_widget_set_margin_start(workspace->relation_details_title_label, 16); gtk_widget_set_margin_end(workspace->relation_details_title_label, 16); @@ -1545,11 +1612,23 @@ Workspace *workspace_new(void) GTK_BOX(workspace->relation_details_panel), workspace->relation_details_title_label ); + gtk_widget_set_tooltip_text(workspace->close_relation_details_button, + "Fermer le volet"); + gtk_widget_add_css_class(workspace->close_relation_details_button, "flat"); + gtk_box_append(GTK_BOX(workspace->relation_details_panel), + workspace->close_relation_details_button); gtk_box_append( GTK_BOX(workspace->relation_details_panel), workspace->relation_details_summary_label ); + gtk_box_append(GTK_BOX(workspace->relation_details_panel), + workspace->edit_relation_button); + g_signal_connect(workspace->edit_relation_button, "clicked", + G_CALLBACK(workspace_on_edit_relation_clicked), workspace); + g_signal_connect(workspace->close_relation_details_button, "clicked", + G_CALLBACK(workspace_on_close_relation_details_clicked), workspace); gtk_widget_set_visible(workspace->relation_details_panel, FALSE); + gtk_widget_set_can_target(workspace->relation_details_panel, FALSE); investigation_graph_view_set_selection_callback( workspace->graph_view, @@ -2906,6 +2985,14 @@ void workspace_set_add_relation_callback( user_data; } +void workspace_set_edit_relation_callback(Workspace *workspace, + WorkspaceEditRelationCallback callback, gpointer user_data) +{ + if (workspace == NULL) return; + workspace->edit_relation_callback = callback; + workspace->edit_relation_user_data = user_data; +} + void workspace_reset_graph_layout( Workspace *workspace ) @@ -2942,6 +3029,7 @@ void workspace_free(Workspace *workspace) &workspace->osint_selection_context, osint_selection_context_free ); + g_clear_pointer(&workspace->selected_relation_identifier, g_free); g_clear_pointer( &workspace->osint_action_catalog, diff --git a/tests/test_relation_dao.c b/tests/test_relation_dao.c index 244a302..6b9e885 100644 --- a/tests/test_relation_dao.c +++ b/tests/test_relation_dao.c @@ -2225,6 +2225,47 @@ static void test_relation_dao_foreign_key_restrict(void) ); } +/** @brief Vérifie la modification des attributs sans changer les extrémités. */ +static void test_relation_dao_update_existing(void) +{ + static const char identifier[] = + "90000000-0000-4000-8000-000000000001"; + TestRelationDaoFixture fixture = test_relation_dao_fixture_create(); + RelationRecord *initial = NULL; + RelationRecord *updated = NULL; + RelationRecord *stored = NULL; + GError *error = NULL; + + test_relation_dao_insert_standard_entities(&fixture); + initial = test_relation_dao_create_relation(identifier, + TEST_ENTITY_A_IDENTIFIER, TEST_ENTITY_B_IDENTIFIER, "uses", + "Ancien libellé", "Ancienne justification", 40, + "2026-07-20T20:00:00Z", "2026-07-20T20:00:00Z", + RELATION_STATUS_ACTIVE); + test_relation_dao_insert_relation(&fixture, initial); + updated = test_relation_dao_create_relation(identifier, + TEST_ENTITY_A_IDENTIFIER, TEST_ENTITY_B_IDENTIFIER, "controls", + "Nouveau libellé", "Nouvelle justification", 90, + "2026-07-20T20:00:00Z", "2026-07-22T16:00:00Z", + RELATION_STATUS_ACTIVE); + assert(relation_dao_update(fixture.relation_dao, updated, &error)); + assert(error == NULL); + stored = relation_dao_find_by_identifier(fixture.relation_dao, + identifier, &error); + assert(stored != NULL && error == NULL); + assert(strcmp(relation_record_get_relation_type(stored), "controls") == 0); + assert(strcmp(relation_record_get_label(stored), "Nouveau libellé") == 0); + assert(relation_record_get_confidence(stored) == 90); + assert(strcmp(relation_record_get_source_entity_identifier(stored), + TEST_ENTITY_A_IDENTIFIER) == 0); + assert(strcmp(relation_record_get_created_at(stored), + "2026-07-20T20:00:00Z") == 0); + relation_record_free(stored); + relation_record_free(updated); + relation_record_free(initial); + test_relation_dao_fixture_clear(&fixture); +} + int main( int argc, char **argv @@ -2256,6 +2297,7 @@ int main( test_relation_dao_missing_table(); test_relation_dao_reflexive_schema_constraint(); test_relation_dao_foreign_key_restrict(); + test_relation_dao_update_existing(); printf( "RelationDao : tous les tests sont valides.\n"