feat(graph): add interactive relation details and synchronized labels

This commit is contained in:
grayTerminal-sh 2026-07-22 18:15:48 +02:00
parent a683266468
commit 8d5a5910db
12 changed files with 642 additions and 10 deletions

View file

@ -93,6 +93,17 @@ gboolean relation_dao_insert(
GError **error 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. * @brief Recherche une relation par son UUID.
* *

View file

@ -14,6 +14,7 @@ G_BEGIN_DECLS
* @brief Résultat opaque produit par le dialogue. * @brief Résultat opaque produit par le dialogue.
*/ */
typedef struct CreateRelationDialogResult CreateRelationDialogResult; typedef struct CreateRelationDialogResult CreateRelationDialogResult;
typedef struct RelationRecord RelationRecord;
/** /**
* @brief Codes d'erreur produits par CreateRelationDialog. * @brief Codes d'erreur produits par CreateRelationDialog.
@ -82,6 +83,21 @@ gboolean create_relation_dialog_present(
GError **error 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. * @brief Valide les valeurs obligatoires d'une relation.
* *

View file

@ -137,6 +137,12 @@ typedef void (*MainWindowAddRelationCallback)(
gpointer user_data 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. * @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 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. * @brief Définit le callback de déclenchement des actions OSINT.
* *
@ -419,6 +432,12 @@ void main_window_set_graph(
const InvestigationGraphLayout *graph_layout 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. * @brief Affiche une erreur de chargement du graphe.
* *

View file

@ -78,6 +78,12 @@ typedef void (*WorkspaceAddRelationCallback)(
gpointer user_data 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. * @brief Callback appelé lors du déclenchement d'une action OSINT.
* *
@ -288,6 +294,13 @@ void workspace_set_add_relation_callback(
gpointer user_data 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. * @brief Affiche l'état de chargement du graphe.
* *

View file

@ -40,6 +40,7 @@
#include "views/osint_execution_history_dialog.h" #include "views/osint_execution_history_dialog.h"
#include "dao/evidence_dao.h" #include "dao/evidence_dao.h"
#include "dao/entity_dao.h" #include "dao/entity_dao.h"
#include "dao/relation_dao.h"
#include "dao/osint_execution_dao.h" #include "dao/osint_execution_dao.h"
#include "models/evidence_type.h" #include "models/evidence_type.h"
#include "widgets/evidence_list_model.h" #include "widgets/evidence_list_model.h"
@ -52,6 +53,7 @@
#include "core/eml_processing.h" #include "core/eml_processing.h"
#include "core/eml_integration.h" #include "core/eml_integration.h"
#include "database/database.h" #include "database/database.h"
#include "database/transaction.h"
#include "views/create_social_account_dialog.h" #include "views/create_social_account_dialog.h"
#include "views/create_person_dialog.h" #include "views/create_person_dialog.h"
#include "views/eml_analysis_dialog.h" #include "views/eml_analysis_dialog.h"
@ -123,6 +125,7 @@ struct Application
guint64 graph_load_generation; guint64 graph_load_generation;
char *selected_evidence_identifier; char *selected_evidence_identifier;
char *pending_relation_selection_identifier;
}; };
/** /**
@ -737,6 +740,14 @@ static void application_on_graph_loaded(
application->graph_layout 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 = status_message =
g_strdup_printf( g_strdup_printf(
"Graphe chargé : %" G_GUINT64_FORMAT "Graphe chargé : %" G_GUINT64_FORMAT
@ -4917,6 +4928,136 @@ cleanup:
/** /**
* @brief Ouvre le dialogue de création depuis la fiche d'une entité. * @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( static void application_on_add_relation_requested(
const char *source_entity_identifier, const char *source_entity_identifier,
gpointer user_data gpointer user_data
@ -6333,6 +6474,9 @@ static void application_on_activate(
application application
); );
main_window_set_edit_relation_callback(application->main_window,
application_on_edit_relation_requested, application);
main_window_set_osint_action_callback( main_window_set_osint_action_callback(
application->main_window, application->main_window,
application_on_osint_action_requested, application_on_osint_action_requested,
@ -6606,6 +6750,8 @@ void application_free(
&application->selected_evidence_identifier, &application->selected_evidence_identifier,
g_free g_free
); );
g_clear_pointer(&application->pending_relation_selection_identifier,
g_free);
g_free( g_free(
application application

View file

@ -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. * @brief Requête de recherche par UUID.
*/ */
@ -1298,6 +1304,65 @@ cleanup:
return success; 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( RelationRecord *relation_dao_find_by_identifier(
RelationDao *relation_dao, RelationDao *relation_dao,
const char *identifier, const char *identifier,

View file

@ -6,6 +6,7 @@
#include "views/create_relation_dialog.h" #include "views/create_relation_dialog.h"
#include "models/entity_record.h" #include "models/entity_record.h"
#include "models/relation_record.h"
#include <glib.h> #include <glib.h>
@ -683,9 +684,10 @@ cleanup:
return valid; return valid;
} }
gboolean create_relation_dialog_present( static gboolean create_relation_dialog_present_internal(
GtkWindow *parent, GtkWindow *parent,
const char *source_entity_identifier, const char *source_entity_identifier,
const RelationRecord *existing_relation,
const GPtrArray *entities, const GPtrArray *entities,
CreateRelationDialogCallback callback, CreateRelationDialogCallback callback,
gpointer user_data, gpointer user_data,
@ -746,6 +748,9 @@ gboolean create_relation_dialog_present(
guint entity_index = guint entity_index =
0; 0;
guint selected_target_index =
0;
g_return_val_if_fail( g_return_val_if_fail(
error == NULL || *error == NULL, error == NULL || *error == NULL,
FALSE FALSE
@ -900,6 +905,13 @@ gboolean create_relation_dialog_present(
entity_identifier 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 || if (display_text == NULL ||
identifier_copy == NULL) identifier_copy == NULL)
{ {
@ -971,7 +983,9 @@ gboolean create_relation_dialog_present(
gtk_window_set_title( gtk_window_set_title(
state->window, state->window,
"Ajouter une relation" existing_relation != NULL
? "Modifier la relation"
: "Ajouter une relation"
); );
gtk_window_set_transient_for( gtk_window_set_transient_for(
@ -1030,7 +1044,9 @@ gboolean create_relation_dialog_present(
GTK_LABEL( GTK_LABEL(
title_label title_label
), ),
"<b>Créer une relation entre deux entités</b>" existing_relation != NULL
? "<b>Modifier la relation</b>"
: "<b>Créer une relation entre deux entités</b>"
); );
gtk_label_set_xalign( gtk_label_set_xalign(
@ -1179,9 +1195,12 @@ gboolean create_relation_dialog_present(
gtk_drop_down_set_selected( gtk_drop_down_set_selected(
state->target_drop_down, 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_set_hexpand(
GTK_WIDGET( GTK_WIDGET(
state->target_drop_down state->target_drop_down
@ -1220,7 +1239,9 @@ gboolean create_relation_dialog_present(
gtk_spin_button_set_value( gtk_spin_button_set_value(
state->confidence_spin_button, state->confidence_spin_button,
80.0 existing_relation != NULL
? relation_record_get_confidence(existing_relation)
: 80.0
); );
state->justification_text_view = state->justification_text_view =
@ -1228,6 +1249,22 @@ gboolean create_relation_dialog_present(
gtk_text_view_new() 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( gtk_text_view_set_wrap_mode(
state->justification_text_view, state->justification_text_view,
GTK_WRAP_WORD_CHAR GTK_WRAP_WORD_CHAR
@ -1418,7 +1455,7 @@ gboolean create_relation_dialog_present(
create_button = create_button =
gtk_button_new_with_label( gtk_button_new_with_label(
"Ajouter" existing_relation != NULL ? "Enregistrer" : "Ajouter"
); );
gtk_widget_add_css_class( gtk_widget_add_css_class(
@ -1543,6 +1580,30 @@ gboolean create_relation_dialog_present(
return TRUE; 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( void create_relation_dialog_result_free(
CreateRelationDialogResult *result CreateRelationDialogResult *result
) )

View file

@ -129,6 +129,9 @@ struct MainWindow
gpointer gpointer
add_relation_user_data; add_relation_user_data;
MainWindowEditRelationCallback edit_relation_callback;
gpointer edit_relation_user_data;
MainWindowOsintActionCallback MainWindowOsintActionCallback
osint_action_callback; 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. * @brief Relaie la demande de vérification provenant du Workspace.
*/ */
@ -847,6 +864,9 @@ MainWindow *main_window_new(
main_window main_window
); );
workspace_set_edit_relation_callback(main_window->workspace,
main_window_on_edit_relation_requested, main_window);
workspace_set_osint_action_callback( workspace_set_osint_action_callback(
main_window->workspace, main_window->workspace,
main_window_on_osint_action_requested, 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( void main_window_set_graph_error(
MainWindow *main_window, MainWindow *main_window,
const char *message const char *message
@ -1614,6 +1642,14 @@ void main_window_set_add_relation_callback(
user_data; 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( void main_window_set_quit_callback(
MainWindow *main_window, MainWindow *main_window,
MainWindowQuitCallback callback, MainWindowQuitCallback callback,
@ -1765,6 +1801,9 @@ void main_window_free(
NULL NULL
); );
workspace_set_edit_relation_callback(main_window->workspace,
NULL, NULL);
main_window->graph_node_moved_callback = main_window->graph_node_moved_callback =
NULL; NULL;
@ -1783,6 +1822,9 @@ void main_window_free(
main_window->add_relation_user_data = main_window->add_relation_user_data =
NULL; NULL;
main_window->edit_relation_callback = NULL;
main_window->edit_relation_user_data = NULL;
main_window->show_graph_callback = main_window->show_graph_callback =
NULL; NULL;

View file

@ -923,6 +923,7 @@ void entity_details_panel_set_entity(
), ),
TRUE TRUE
); );
gtk_widget_set_can_target(details_panel->root_revealer, TRUE);
g_free( g_free(
confidence_text confidence_text
@ -1009,6 +1010,8 @@ void entity_details_panel_clear(
), ),
FALSE FALSE
); );
/* Le revealer fermé ne doit pas bloquer le canvas sous l'overlay. */
gtk_widget_set_can_target(details_panel->root_revealer, FALSE);
} }
} }

View file

@ -61,6 +61,9 @@ typedef struct
double width; double width;
double height; double height;
double relation_offset_x;
double relation_offset_y;
} InvestigationGraphNodeLayout; } InvestigationGraphNodeLayout;
/** /**
@ -130,6 +133,58 @@ struct InvestigationGraphView
static const char *investigation_graph_view_get_node_identifier( static const char *investigation_graph_view_get_node_identifier(
const InvestigationGraphNodeLayout *node_layout 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. * @brief Borne un facteur de zoom aux limites autorisées.
@ -577,6 +632,8 @@ investigation_graph_view_find_node_at(
return NULL; return NULL;
} }
investigation_graph_view_sync_relation_layouts(graph_view);
layout_position = layout_position =
graph_view->node_layouts->len; 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; return NULL;
} }
@ -1086,6 +1200,8 @@ static void investigation_graph_view_on_drag_update(
logical_y - logical_y -
graph_view->node_drag_pointer_offset_y; graph_view->node_drag_pointer_offset_y;
investigation_graph_view_sync_relation_layouts(graph_view);
gtk_widget_queue_draw( gtk_widget_queue_draw(
graph_view->drawing_area graph_view->drawing_area
); );
@ -1216,6 +1332,8 @@ static void investigation_graph_view_on_drag_end(
graph_view->dragged_node->y = graph_view->dragged_node->y =
logical_y - logical_y -
graph_view->node_drag_pointer_offset_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 parallel_offset
); );
relation_layout->relation_offset_x =
perpendicular_x * parallel_offset;
relation_layout->y = relation_layout->y =
( (
(source_center_y + target_center_y) / (source_center_y + target_center_y) /
@ -2031,6 +2152,9 @@ static gboolean investigation_graph_view_append_relation_layouts(
parallel_offset parallel_offset
); );
relation_layout->relation_offset_y =
perpendicular_y * parallel_offset;
g_ptr_array_add( g_ptr_array_add(
node_layouts, node_layouts,
relation_layout relation_layout
@ -3686,6 +3810,8 @@ static void investigation_graph_view_draw(
return; return;
} }
investigation_graph_view_sync_relation_layouts(graph_view);
/* /*
* Les coordonnées de disposition restent exprimées dans l'espace logique * Les coordonnées de disposition restent exprimées dans l'espace logique
* du graphe. Cairo applique ensuite la navigation de la vue. * du graphe. Cairo applique ensuite la navigation de la vue.

View file

@ -67,6 +67,9 @@ struct Workspace
GtkWidget *relation_details_panel; GtkWidget *relation_details_panel;
GtkWidget *relation_details_title_label; GtkWidget *relation_details_title_label;
GtkWidget *relation_details_summary_label; GtkWidget *relation_details_summary_label;
GtkWidget *edit_relation_button;
GtkWidget *close_relation_details_button;
char *selected_relation_identifier;
GtkWidget *node_page; GtkWidget *node_page;
@ -119,6 +122,9 @@ struct Workspace
gpointer gpointer
add_relation_user_data; add_relation_user_data;
WorkspaceEditRelationCallback edit_relation_callback;
gpointer edit_relation_user_data;
WorkspaceOsintActionCallback WorkspaceOsintActionCallback
osint_action_callback; 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. * @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, &workspace->osint_selection_context,
osint_selection_context_free osint_selection_context_free
); );
g_clear_pointer(&workspace->selected_relation_identifier, g_free);
if (entity_record != NULL) if (entity_record != NULL)
{ {
@ -640,6 +661,8 @@ static void workspace_on_graph_node_selected(
if (workspace->relation_details_panel != NULL) if (workspace->relation_details_panel != NULL)
{ {
gtk_widget_set_can_target(workspace->relation_details_panel,
relation_record != NULL);
gtk_widget_set_visible( gtk_widget_set_visible(
workspace->relation_details_panel, workspace->relation_details_panel,
relation_record != NULL relation_record != NULL
@ -650,6 +673,8 @@ static void workspace_on_graph_node_selected(
workspace->relation_details_title_label != NULL && workspace->relation_details_title_label != NULL &&
workspace->relation_details_summary_label != NULL) workspace->relation_details_summary_label != NULL)
{ {
workspace->selected_relation_identifier = g_strdup(
relation_record_get_identifier(relation_record));
const EntityRecord *source_entity = const EntityRecord *source_entity =
investigation_graph_model_find_entity( investigation_graph_model_find_entity(
workspace->graph_model, workspace->graph_model,
@ -670,11 +695,21 @@ static void workspace_on_graph_node_selected(
); );
const char *label = relation_record_get_label(relation_record); const char *label = relation_record_get_label(relation_record);
char *summary = g_strdup_printf( char *summary = g_strdup_printf(
"%s → %s\n\nType : %s\nConfiance : %d %%\nUUID : %s", "%s → %s\n\nType : %s\nConfiance : %d %%\nStatut : %s\n"
entity_record_get_value(source_entity), "Justification : %s\nCréée le : %s\nModifiée le : %s\nUUID : %s",
entity_record_get_value(target_entity), 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_relation_type(relation_record),
relation_record_get_confidence(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) 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. * @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_title_label = gtk_label_new(NULL);
workspace->relation_details_summary_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 || if (workspace->graph_view == NULL ||
workspace->entity_details_panel == NULL || workspace->entity_details_panel == NULL ||
workspace->relation_details_panel == NULL || workspace->relation_details_panel == NULL ||
workspace->relation_details_title_label == 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_free(
workspace workspace
@ -1514,6 +1579,8 @@ Workspace *workspace_new(void)
gtk_widget_set_margin_top(workspace->relation_details_panel, 56); gtk_widget_set_margin_top(workspace->relation_details_panel, 56);
gtk_widget_set_margin_start(workspace->relation_details_panel, 12); gtk_widget_set_margin_start(workspace->relation_details_panel, 12);
gtk_widget_set_margin_bottom(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_add_css_class(workspace->relation_details_panel, "card");
gtk_widget_set_margin_start(workspace->relation_details_title_label, 16); gtk_widget_set_margin_start(workspace->relation_details_title_label, 16);
gtk_widget_set_margin_end(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), GTK_BOX(workspace->relation_details_panel),
workspace->relation_details_title_label 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_append(
GTK_BOX(workspace->relation_details_panel), GTK_BOX(workspace->relation_details_panel),
workspace->relation_details_summary_label 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_visible(workspace->relation_details_panel, FALSE);
gtk_widget_set_can_target(workspace->relation_details_panel, FALSE);
investigation_graph_view_set_selection_callback( investigation_graph_view_set_selection_callback(
workspace->graph_view, workspace->graph_view,
@ -2906,6 +2985,14 @@ void workspace_set_add_relation_callback(
user_data; 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( void workspace_reset_graph_layout(
Workspace *workspace Workspace *workspace
) )
@ -2942,6 +3029,7 @@ void workspace_free(Workspace *workspace)
&workspace->osint_selection_context, &workspace->osint_selection_context,
osint_selection_context_free osint_selection_context_free
); );
g_clear_pointer(&workspace->selected_relation_identifier, g_free);
g_clear_pointer( g_clear_pointer(
&workspace->osint_action_catalog, &workspace->osint_action_catalog,

View file

@ -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 main(
int argc, int argc,
char **argv char **argv
@ -2256,6 +2297,7 @@ int main(
test_relation_dao_missing_table(); test_relation_dao_missing_table();
test_relation_dao_reflexive_schema_constraint(); test_relation_dao_reflexive_schema_constraint();
test_relation_dao_foreign_key_restrict(); test_relation_dao_foreign_key_restrict();
test_relation_dao_update_existing();
printf( printf(
"RelationDao : tous les tests sont valides.\n" "RelationDao : tous les tests sont valides.\n"