From c46039a2aa7952259c5913c0a1d26f4669f670bc Mon Sep 17 00:00:00 2001 From: grayTerminal-sh Date: Tue, 21 Jul 2026 23:27:07 +0200 Subject: [PATCH] =?UTF-8?q?Persister=20la=20position=20des=20n=C5=93uds=20?= =?UTF-8?q?du=20graphe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 3 + database/schema_current.sql | 59 ++ include/core/investigation_graph_load_task.h | 22 + include/dao/graph_node_position_dao.h | 133 ++++ include/database/schema.h | 19 + include/database/statement.h | 35 + include/models/graph_node_position.h | 126 +++ include/models/investigation_graph_layout.h | 133 ++++ include/views/main_window.h | 45 +- include/widgets/investigation_graph_view.h | 61 ++ include/widgets/workspace.h | 49 +- src/core/application.c | 243 +++++- src/core/investigation_graph_load_task.c | 244 +++++- src/dao/graph_node_position_dao.c | 793 +++++++++++++++++++ src/database/database.c | 73 ++ src/database/schema.c | 11 + src/database/statement.c | 124 +++ src/models/graph_node_position.c | 336 ++++++++ src/models/investigation_graph_layout.c | 321 ++++++++ src/views/main_window.c | 77 +- src/widgets/investigation_graph_view.c | 230 +++++- src/widgets/workspace.c | 103 ++- 22 files changed, 3223 insertions(+), 17 deletions(-) create mode 100644 database/schema_current.sql create mode 100644 include/dao/graph_node_position_dao.h create mode 100644 include/models/graph_node_position.h create mode 100644 include/models/investigation_graph_layout.h create mode 100644 src/dao/graph_node_position_dao.c create mode 100644 src/models/graph_node_position.c create mode 100644 src/models/investigation_graph_layout.c diff --git a/Makefile b/Makefile index f10f692..de83c2b 100644 --- a/Makefile +++ b/Makefile @@ -516,6 +516,9 @@ $(TEST_INVESTIGATION_GRAPH_LOAD_TASK): \ src/core/investigation_graph_loader.c \ src/core/background_task.c \ src/dao/entity_dao.c \ + src/models/investigation_graph_layout.c \ + src/models/graph_node_position.c \ + src/dao/graph_node_position_dao.c \ src/dao/relation_dao.c \ src/models/investigation_graph_model.c \ src/models/entity_record.c \ diff --git a/database/schema_current.sql b/database/schema_current.sql new file mode 100644 index 0000000..67a5304 --- /dev/null +++ b/database/schema_current.sql @@ -0,0 +1,59 @@ +/****************************************************************************** + * Labfy Investigation + * + * Extensions idempotentes du schéma SQLite courant V2 + ******************************************************************************/ + +/* + * Les coordonnées du graphe sont un état de présentation. + * + * Elles restent séparées des données métier de la table entites. + * + * Cette table peut être créée à chaque ouverture grâce à IF NOT EXISTS. + */ +CREATE TABLE IF NOT EXISTS graph_node_positions +( + entity_id TEXT PRIMARY KEY, + + x REAL NOT NULL, + y REAL NOT NULL, + + updated_at TEXT NOT NULL, + + FOREIGN KEY (entity_id) + REFERENCES entites(id) + ON UPDATE CASCADE + ON DELETE CASCADE, + + CHECK ( + length(trim(entity_id)) > 0 + ), + + CHECK ( + typeof(x) IN ( + 'integer', + 'real' + ) + ), + + CHECK ( + typeof(y) IN ( + 'integer', + 'real' + ) + ), + + CHECK ( + x = x + AND abs(x) <= 1.7976931348623157e308 + ), + + CHECK ( + y = y + AND abs(y) <= 1.7976931348623157e308 + ), + + CHECK ( + length(trim(updated_at)) > 0 + ) +); diff --git a/include/core/investigation_graph_load_task.h b/include/core/investigation_graph_load_task.h index 0695c7d..6c16009 100644 --- a/include/core/investigation_graph_load_task.h +++ b/include/core/investigation_graph_load_task.h @@ -6,6 +6,7 @@ #ifndef LABFY_INVESTIGATION_INVESTIGATION_GRAPH_LOAD_TASK_H #define LABFY_INVESTIGATION_INVESTIGATION_GRAPH_LOAD_TASK_H +#include "models/investigation_graph_layout.h" #include "models/investigation_graph_model.h" #include @@ -74,6 +75,10 @@ typedef enum * * En cas de succès, graph_model appartient au destinataire du callback. * + * La disposition associée peut être récupérée pendant le callback avec + * investigation_graph_load_task_take_layout(). Elle devient alors la + * propriété du code appelant. + * * En cas d'échec, error est empruntée et valable uniquement pendant le * callback. * @@ -171,6 +176,23 @@ gboolean investigation_graph_load_task_is_running( const InvestigationGraphLoadTask *load_task ); +/** + * @brief Transfère la disposition chargée au code appelant. + * + * Cette fonction doit être appelée après une fin de chargement réussie, + * normalement depuis InvestigationGraphLoadTaskCallback. + * + * La tâche cesse de posséder la disposition retournée. + * Un second appel retourne NULL. + * + * @param load_task Tâche terminée. + * + * @return Disposition transférée, ou NULL. + */ +InvestigationGraphLayout *investigation_graph_load_task_take_layout( + InvestigationGraphLoadTask *load_task +); + G_END_DECLS #endif diff --git a/include/dao/graph_node_position_dao.h b/include/dao/graph_node_position_dao.h new file mode 100644 index 0000000..35444d9 --- /dev/null +++ b/include/dao/graph_node_position_dao.h @@ -0,0 +1,133 @@ +/****************************************************************************** + * @file graph_node_position_dao.h + * @brief Persistance des positions des nœuds du graphe dans SQLite. + ******************************************************************************/ + +#ifndef LABFY_INVESTIGATION_GRAPH_NODE_POSITION_DAO_H +#define LABFY_INVESTIGATION_GRAPH_NODE_POSITION_DAO_H + +#include "database/database.h" +#include "models/graph_node_position.h" + +#include + +G_BEGIN_DECLS + +/** + * @brief Catégories d'erreurs du DAO des positions de nœuds. + */ +typedef enum +{ + GRAPH_NODE_POSITION_DAO_ERROR_INVALID_ARGUMENT, + GRAPH_NODE_POSITION_DAO_ERROR_MEMORY, + GRAPH_NODE_POSITION_DAO_ERROR_PREPARE, + GRAPH_NODE_POSITION_DAO_ERROR_BIND, + GRAPH_NODE_POSITION_DAO_ERROR_EXECUTE, + GRAPH_NODE_POSITION_DAO_ERROR_CONSTRAINT, + GRAPH_NODE_POSITION_DAO_ERROR_READ, + GRAPH_NODE_POSITION_DAO_ERROR_MODEL +} GraphNodePositionDaoError; + +/** + * @brief Domaine d'erreurs du DAO des positions de nœuds. + */ +#define GRAPH_NODE_POSITION_DAO_ERROR \ + graph_node_position_dao_error_quark() + +/** + * @brief Retourne le domaine d'erreurs du DAO. + */ +GQuark graph_node_position_dao_error_quark(void); + +/** + * @brief DAO opaque donnant accès aux positions persistées. + * + * L'objet emprunte la connexion Database reçue lors de sa création. + */ +typedef struct GraphNodePositionDao GraphNodePositionDao; + +/** + * @brief Crée un DAO utilisant une connexion Database existante. + * + * La connexion est empruntée et doit rester valide pendant toute la durée + * de vie du DAO. + * + * @param database Connexion ouverte. + * @param error Adresse recevant une éventuelle erreur. + * + * @return Nouveau DAO, ou NULL en cas d'échec. + */ +GraphNodePositionDao *graph_node_position_dao_new( + Database *database, + GError **error +); + +/** + * @brief Libère le DAO. + * + * La connexion Database empruntée n'est pas fermée. + * Cette fonction accepte NULL. + * + * @param position_dao DAO à libérer. + */ +void graph_node_position_dao_free( + GraphNodePositionDao *position_dao +); + +/** + * @brief Charge toutes les positions dans un ordre déterministe. + * + * Le tableau retourné utilise graph_node_position_free() comme fonction + * de destruction. + * + * @param position_dao DAO valide. + * @param error Adresse recevant une éventuelle erreur. + * + * @return Nouveau GPtrArray possédé par l'appelant, ou NULL. + */ +GPtrArray *graph_node_position_dao_list_all( + GraphNodePositionDao *position_dao, + GError **error +); + +/** + * @brief Insère ou met à jour la position d'une entité. + * + * La date updated_at est générée en UTC par le DAO. + * + * @param position_dao DAO valide. + * @param entity_identifier UUID de l'entité. + * @param x Coordonnée horizontale logique. + * @param y Coordonnée verticale logique. + * @param error Adresse recevant une éventuelle erreur. + * + * @return TRUE si l'enregistrement réussit, sinon FALSE. + */ +gboolean graph_node_position_dao_upsert( + GraphNodePositionDao *position_dao, + const char *entity_identifier, + double x, + double y, + GError **error +); + +/** + * @brief Supprime la position persistée d'une entité. + * + * L'absence de position n'est pas une erreur. + * + * @param position_dao DAO valide. + * @param entity_identifier UUID de l'entité. + * @param error Adresse recevant une éventuelle erreur. + * + * @return TRUE si la requête réussit, sinon FALSE. + */ +gboolean graph_node_position_dao_delete( + GraphNodePositionDao *position_dao, + const char *entity_identifier, + GError **error +); + +G_END_DECLS + +#endif diff --git a/include/database/schema.h b/include/database/schema.h index 5f8998a..ffafd4b 100644 --- a/include/database/schema.h +++ b/include/database/schema.h @@ -54,4 +54,23 @@ bool schema_install_v2( Database *database ); +/** + * @brief Garantit la présence des extensions du schéma courant V2. + * + * La connexion doit être valide et une transaction doit déjà être active. + * + * Cette fonction applique uniquement des opérations idempotentes destinées + * aux nouvelles bases comme aux bases V2 déjà existantes. + * + * Elle ne modifie pas la version enregistrée dans metadata. + * Elle ne réalise ni COMMIT ni ROLLBACK. + * + * @param database Connexion Database ouverte. + * + * @return true si les extensions sont présentes, sinon false. + */ +bool schema_ensure_current( + Database *database +); + #endif diff --git a/include/database/statement.h b/include/database/statement.h index e8e4024..a430a3b 100644 --- a/include/database/statement.h +++ b/include/database/statement.h @@ -119,6 +119,26 @@ bool database_statement_column_int64( int64_t *value ); +/** + * @brief Lit un nombre à virgule flottante depuis une colonne. + * + * Les indices de colonnes SQLite commencent à 0. + * + * La colonne doit être de type SQLITE_FLOAT ou SQLITE_INTEGER. + * Une colonne entière est convertie en double par SQLite. + * + * @param statement Requête positionnée sur une ligne. + * @param column_index Indice de la colonne. + * @param value Destination de la valeur. + * + * @return true si le nombre a pu être lu, sinon false. + */ +bool database_statement_column_double( + DatabaseStatement *statement, + int column_index, + double *value +); + /** * @brief Copie le contenu texte d'une colonne. * @@ -173,6 +193,21 @@ bool database_statement_bind_int64( int64_t value ); +/** + * @brief Lie un nombre à virgule flottante à un paramètre SQL. + * + * @param statement Requête préparée. + * @param index Indice du paramètre. + * @param value Valeur à lier. + * + * @return true en cas de succès, sinon false. + */ +bool database_statement_bind_double( + DatabaseStatement *statement, + int index, + double value +); + /** * @brief Lie la valeur SQL NULL à un paramètre. * diff --git a/include/models/graph_node_position.h b/include/models/graph_node_position.h new file mode 100644 index 0000000..6d5324b --- /dev/null +++ b/include/models/graph_node_position.h @@ -0,0 +1,126 @@ +/****************************************************************************** + * @file graph_node_position.h + * @brief Modèle représentant la position persistée d'un nœud du graphe. + ******************************************************************************/ + +#ifndef LABFY_INVESTIGATION_GRAPH_NODE_POSITION_H +#define LABFY_INVESTIGATION_GRAPH_NODE_POSITION_H + +#include + +G_BEGIN_DECLS + +/** + * @brief Modèle opaque représentant une position de nœud. + */ +typedef struct GraphNodePosition GraphNodePosition; + +/** + * @brief Codes d'erreur produits par GraphNodePosition. + */ +typedef enum +{ + GRAPH_NODE_POSITION_ERROR_INVALID_ARGUMENT, + GRAPH_NODE_POSITION_ERROR_INVALID_IDENTIFIER, + GRAPH_NODE_POSITION_ERROR_INVALID_COORDINATE, + GRAPH_NODE_POSITION_ERROR_INVALID_DATE +} GraphNodePositionError; + +/** + * @brief Domaine d'erreur du modèle GraphNodePosition. + */ +#define GRAPH_NODE_POSITION_ERROR \ + graph_node_position_error_quark() + +/** + * @brief Retourne le domaine d'erreur du modèle. + */ +GQuark graph_node_position_error_quark(void); + +/** + * @brief Crée une position persistée de nœud. + * + * Toutes les chaînes sont copiées. + * + * entity_identifier doit être un UUID valide. + * x et y doivent être des nombres finis. + * updated_at doit respecter le format UTC YYYY-MM-DDTHH:MM:SSZ. + * + * @param entity_identifier UUID de l'entité. + * @param x Coordonnée horizontale logique. + * @param y Coordonnée verticale logique. + * @param updated_at Date UTC de dernière modification. + * @param error Emplacement facultatif recevant une erreur. + * + * @return Nouvelle position, ou NULL lorsque les données sont invalides. + */ +GraphNodePosition *graph_node_position_new( + const char *entity_identifier, + double x, + double y, + const char *updated_at, + GError **error +); + +/** + * @brief Libère une position. + * + * Cette fonction accepte NULL. + * + * @param position Position à libérer. + */ +void graph_node_position_free( + GraphNodePosition *position +); + +/** + * @brief Retourne l'UUID de l'entité. + * + * La chaîne retournée appartient au modèle. + * + * @param position Position à consulter. + * + * @return UUID emprunté, ou NULL. + */ +const char *graph_node_position_get_entity_identifier( + const GraphNodePosition *position +); + +/** + * @brief Retourne la coordonnée horizontale. + * + * @param position Position à consulter. + * + * @return Coordonnée horizontale, ou 0.0 si position est NULL. + */ +double graph_node_position_get_x( + const GraphNodePosition *position +); + +/** + * @brief Retourne la coordonnée verticale. + * + * @param position Position à consulter. + * + * @return Coordonnée verticale, ou 0.0 si position est NULL. + */ +double graph_node_position_get_y( + const GraphNodePosition *position +); + +/** + * @brief Retourne la date UTC de dernière modification. + * + * La chaîne retournée appartient au modèle. + * + * @param position Position à consulter. + * + * @return Date empruntée, ou NULL. + */ +const char *graph_node_position_get_updated_at( + const GraphNodePosition *position +); + +G_END_DECLS + +#endif diff --git a/include/models/investigation_graph_layout.h b/include/models/investigation_graph_layout.h new file mode 100644 index 0000000..32d62f2 --- /dev/null +++ b/include/models/investigation_graph_layout.h @@ -0,0 +1,133 @@ +/****************************************************************************** + * @file investigation_graph_layout.h + * @brief Modèle en mémoire de la disposition du graphe d'enquête. + ******************************************************************************/ + +#ifndef LABFY_INVESTIGATION_INVESTIGATION_GRAPH_LAYOUT_H +#define LABFY_INVESTIGATION_INVESTIGATION_GRAPH_LAYOUT_H + +#include + +G_BEGIN_DECLS + +/** + * @brief Modèle opaque représentant la disposition du graphe. + */ +typedef struct InvestigationGraphLayout InvestigationGraphLayout; + +/** + * @brief Codes d'erreur du modèle de disposition. + */ +typedef enum +{ + INVESTIGATION_GRAPH_LAYOUT_ERROR_INVALID_ARGUMENT, + INVESTIGATION_GRAPH_LAYOUT_ERROR_INVALID_IDENTIFIER, + INVESTIGATION_GRAPH_LAYOUT_ERROR_INVALID_COORDINATE, + INVESTIGATION_GRAPH_LAYOUT_ERROR_MEMORY +} InvestigationGraphLayoutError; + +/** + * @brief Domaine d'erreur du modèle de disposition. + */ +#define INVESTIGATION_GRAPH_LAYOUT_ERROR \ + investigation_graph_layout_error_quark() + +/** + * @brief Retourne le domaine d'erreur du modèle. + */ +GQuark investigation_graph_layout_error_quark(void); + +/** + * @brief Crée une disposition vide. + * + * @return Nouvelle disposition, ou NULL en cas d'échec. + */ +InvestigationGraphLayout *investigation_graph_layout_new(void); + +/** + * @brief Enregistre ou remplace la position d'une entité. + * + * L'identifiant est copié. + * + * @param layout Disposition à modifier. + * @param entity_identifier UUID de l'entité. + * @param x Coordonnée horizontale logique. + * @param y Coordonnée verticale logique. + * @param error Adresse recevant une éventuelle erreur. + * + * @return TRUE si la position a été enregistrée, sinon FALSE. + */ +gboolean investigation_graph_layout_set_position( + InvestigationGraphLayout *layout, + const char *entity_identifier, + double x, + double y, + GError **error +); + +/** + * @brief Lit la position d'une entité. + * + * @param layout Disposition à consulter. + * @param entity_identifier UUID de l'entité. + * @param x Destination facultative de la coordonnée horizontale. + * @param y Destination facultative de la coordonnée verticale. + * + * @return TRUE si une position existe, sinon FALSE. + */ +gboolean investigation_graph_layout_get_position( + const InvestigationGraphLayout *layout, + const char *entity_identifier, + double *x, + double *y +); + +/** + * @brief Supprime la position d'une entité. + * + * L'absence de position n'est pas une erreur. + * + * @param layout Disposition à modifier. + * @param entity_identifier UUID de l'entité. + * + * @return TRUE si une position a été supprimée, sinon FALSE. + */ +gboolean investigation_graph_layout_remove_position( + InvestigationGraphLayout *layout, + const char *entity_identifier +); + +/** + * @brief Supprime toutes les positions. + * + * @param layout Disposition à vider. + */ +void investigation_graph_layout_clear( + InvestigationGraphLayout *layout +); + +/** + * @brief Retourne le nombre de positions conservées. + * + * @param layout Disposition à consulter. + * + * @return Nombre de positions. + */ +guint investigation_graph_layout_get_count( + const InvestigationGraphLayout *layout +); + +/** + * @brief Libère la disposition. + * + * Cette fonction accepte NULL. + * + * @param layout Disposition à libérer. + */ +void investigation_graph_layout_free( + InvestigationGraphLayout *layout +); + +G_END_DECLS + +#endif diff --git a/include/views/main_window.h b/include/views/main_window.h index ec77a42..1ac0615 100644 --- a/include/views/main_window.h +++ b/include/views/main_window.h @@ -20,6 +20,11 @@ */ typedef struct InvestigationGraphModel InvestigationGraphModel; +/** + * @brief Représentation opaque de la disposition du graphe. + */ +typedef struct InvestigationGraphLayout InvestigationGraphLayout; + /** * @brief Représentation opaque de la fenêtre principale. */ @@ -71,6 +76,21 @@ typedef void (*MainWindowVerifyEvidenceCallback)( gpointer user_data ); +/** + * @brief Callback appelé après le déplacement effectif d'un nœud. + * + * @param entity_identifier UUID de l'entité déplacée. + * @param x Coordonnée horizontale logique. + * @param y Coordonnée verticale logique. + * @param user_data Données privées du callback. + */ +typedef void (*MainWindowGraphNodeMovedCallback)( + const char *entity_identifier, + double x, + double y, + gpointer user_data +); + /** * @brief Définit le callback de vérification d'une preuve. * @@ -86,6 +106,21 @@ void main_window_set_verify_evidence_callback( gpointer user_data ); +/** + * @brief Définit le callback de fin de déplacement d'un nœud. + * + * MainWindow relaie uniquement l'événement provenant du Workspace. + * + * @param main_window Fenêtre principale. + * @param callback Callback facultatif. + * @param user_data Données privées transmises au callback. + */ +void main_window_set_graph_node_moved_callback( + MainWindow *main_window, + MainWindowGraphNodeMovedCallback callback, + gpointer user_data +); + /** * @brief Définit le callback du bouton « Nouvelle enquête ». * @@ -247,16 +282,20 @@ void main_window_set_graph_loading( ); /** - * @brief Affiche le graphe chargé dans le Workspace. + * @brief Affiche le graphe chargé et sa disposition dans le Workspace. * - * MainWindow et Workspace empruntent graph_model. + * MainWindow et Workspace empruntent graph_model et graph_layout. + * + * Passer NULL pour graph_layout conserve le placement automatique. * * @param main_window Fenêtre principale. * @param graph_model Graphe emprunté, ou NULL. + * @param graph_layout Disposition empruntée, ou NULL. */ void main_window_set_graph( MainWindow *main_window, - const InvestigationGraphModel *graph_model + const InvestigationGraphModel *graph_model, + const InvestigationGraphLayout *graph_layout ); /** diff --git a/include/widgets/investigation_graph_view.h b/include/widgets/investigation_graph_view.h index d5a8bc3..85b6f91 100644 --- a/include/widgets/investigation_graph_view.h +++ b/include/widgets/investigation_graph_view.h @@ -20,6 +20,11 @@ typedef struct EntityRecord EntityRecord; */ typedef struct InvestigationGraphModel InvestigationGraphModel; +/** + * @brief Représentation opaque de la disposition persistée du graphe. + */ +typedef struct InvestigationGraphLayout InvestigationGraphLayout; + /** * @brief Représentation opaque de la vue graphique. */ @@ -38,6 +43,25 @@ typedef void (*InvestigationGraphViewSelectionCallback)( gpointer user_data ); +/** + * @brief Callback appelé après le déplacement effectif d'un nœud. + * + * Les coordonnées sont exprimées dans l'espace logique du graphe. + * entity_identifier est emprunté au modèle et reste valable uniquement + * pendant l'appel. + * + * @param entity_identifier UUID de l'entité déplacée. + * @param x Coordonnée horizontale logique du coin supérieur gauche. + * @param y Coordonnée verticale logique du coin supérieur gauche. + * @param user_data Données empruntées fournies par l'appelant. + */ +typedef void (*InvestigationGraphViewNodeMovedCallback)( + const char *entity_identifier, + double x, + double y, + gpointer user_data +); + /** * @brief Crée une nouvelle vue graphique vide. * @@ -74,6 +98,25 @@ void investigation_graph_view_set_graph( const InvestigationGraphModel *graph_model ); +/** + * @brief Associe une disposition persistée à la vue. + * + * La disposition est empruntée et doit rester valide pendant son utilisation. + * + * Lorsqu'un graphe est déjà affiché, la disposition privée de ses nœuds est + * reconstruite. Une position persistée remplace la position automatique + * uniquement lorsqu'elle existe pour l'entité concernée. + * + * Passer NULL rétablit le placement automatique. + * + * @param graph_view Vue graphique à mettre à jour. + * @param graph_layout Disposition persistée empruntée, ou NULL. + */ +void investigation_graph_view_set_layout( + InvestigationGraphView *graph_view, + const InvestigationGraphLayout *graph_layout +); + /** * @brief Détache le graphe actuellement affiché. * @@ -100,6 +143,24 @@ void investigation_graph_view_set_selection_callback( gpointer user_data ); +/** + * @brief Définit le callback de fin de déplacement d'un nœud. + * + * Le callback et user_data sont empruntés par la vue. + * + * Le callback n'est appelé ni pendant le déplacement du canvas, ni pour + * un simple clic, ni pendant une reconstruction de la disposition. + * + * @param graph_view Vue graphique. + * @param callback Callback à appeler, ou NULL. + * @param user_data Données empruntées du callback. + */ +void investigation_graph_view_set_node_moved_callback( + InvestigationGraphView *graph_view, + InvestigationGraphViewNodeMovedCallback callback, + gpointer user_data +); + /** * @brief Retourne l'entité actuellement sélectionnée. * diff --git a/include/widgets/workspace.h b/include/widgets/workspace.h index 5768228..ce3bac3 100644 --- a/include/widgets/workspace.h +++ b/include/widgets/workspace.h @@ -16,6 +16,11 @@ */ typedef struct InvestigationGraphModel InvestigationGraphModel; +/** + * @brief Représentation opaque de la disposition du graphe. + */ +typedef struct InvestigationGraphLayout InvestigationGraphLayout; + /** * @brief Représentation opaque de la zone de travail. */ @@ -32,6 +37,23 @@ typedef void (*WorkspaceVerifyEvidenceCallback)( gpointer user_data ); +/** + * @brief Callback appelé après le déplacement effectif d'un nœud. + * + * Les coordonnées sont exprimées dans l'espace logique du graphe. + * + * @param entity_identifier UUID de l'entité déplacée. + * @param x Coordonnée horizontale logique. + * @param y Coordonnée verticale logique. + * @param user_data Données empruntées fournies par l'appelant. + */ +typedef void (*WorkspaceGraphNodeMovedCallback)( + const char *entity_identifier, + double x, + double y, + gpointer user_data +); + /** * @brief Crée une nouvelle zone de travail. * @@ -97,6 +119,21 @@ void workspace_set_verify_evidence_callback( gpointer user_data ); +/** + * @brief Définit le callback de fin de déplacement d'un nœud. + * + * Le Workspace relaie uniquement l'événement provenant de la vue graphique. + * + * @param workspace Zone de travail. + * @param callback Callback facultatif. + * @param user_data Données empruntées transmises au callback. + */ +void workspace_set_graph_node_moved_callback( + Workspace *workspace, + WorkspaceGraphNodeMovedCallback callback, + gpointer user_data +); + /** * @brief Affiche l'état de chargement du graphe. * @@ -109,17 +146,21 @@ void workspace_set_graph_loading( ); /** - * @brief Affiche le résumé du graphe chargé. + * @brief Affiche le graphe chargé avec sa disposition persistée. * - * Le Workspace emprunte graph_model et ne le libère jamais. - * Passer NULL équivaut à workspace_clear_graph(). + * Le Workspace emprunte graph_model et graph_layout et ne les libère jamais. + * + * Passer NULL pour graph_model équivaut à workspace_clear_graph(). + * Passer NULL pour graph_layout conserve le placement automatique. * * @param workspace Zone de travail. * @param graph_model Graphe emprunté. + * @param graph_layout Disposition empruntée, ou NULL. */ void workspace_set_graph( Workspace *workspace, - const InvestigationGraphModel *graph_model + const InvestigationGraphModel *graph_model, + const InvestigationGraphLayout *graph_layout ); /** diff --git a/src/core/application.c b/src/core/application.c index 293abcf..64152c6 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -10,6 +10,7 @@ #include "core/investigation_session.h" #include "core/investigation_tree_model.h" #include "core/investigation_graph_load_task.h" +#include "models/investigation_graph_layout.h" #include "models/investigation_graph_model.h" #include "models/investigation_record.h" #include "core/investigation_tree_builder.h" @@ -24,6 +25,7 @@ #include "core/evidence_import_task.h" #include "models/evidence_record.h" #include "dao/evidence_type_dao.h" +#include "dao/graph_node_position_dao.h" #include "views/evidence_import_dialog.h" #include "dao/evidence_dao.h" #include "models/evidence_type.h" @@ -96,6 +98,7 @@ struct Application InvestigationGraphLoadTask *graph_load_task; InvestigationGraphModel *graph_model; + InvestigationGraphLayout *graph_layout; guint64 graph_load_generation; char *selected_evidence_identifier; @@ -291,6 +294,9 @@ static void application_clear_graph( InvestigationGraphModel *graph_model = NULL; + InvestigationGraphLayout *graph_layout = + NULL; + if (application == NULL) { return; @@ -306,12 +312,22 @@ static void application_clear_graph( graph_model = application->graph_model; + graph_layout = + application->graph_layout; + application->graph_model = NULL; + application->graph_layout = + NULL; + investigation_graph_model_free( graph_model ); + + investigation_graph_layout_free( + graph_layout + ); } /** @@ -332,6 +348,9 @@ static void application_on_graph_loaded( Application *application = NULL; + InvestigationGraphLayout *graph_layout = + NULL; + char *status_message = NULL; @@ -344,6 +363,11 @@ static void application_on_graph_loaded( context->application; } + graph_layout = + investigation_graph_load_task_take_layout( + load_task + ); + if (application != NULL) { is_current_result = @@ -363,6 +387,10 @@ static void application_on_graph_loaded( graph_model ); + investigation_graph_layout_free( + graph_layout + ); + investigation_graph_load_task_free( load_task ); @@ -379,6 +407,10 @@ static void application_on_graph_loaded( graph_model ); + investigation_graph_layout_free( + graph_layout + ); + main_window_set_graph_error( application->main_window, error->message @@ -396,11 +428,21 @@ static void application_on_graph_loaded( return; } - if (graph_model == NULL) + if (graph_model == NULL || + graph_layout == NULL) { + investigation_graph_model_free( + graph_model + ); + + investigation_graph_layout_free( + graph_layout + ); + main_window_set_graph_error( application->main_window, - "Le chargement s'est terminé sans fournir de graphe." + "Le chargement s'est terminé sans fournir " + "un graphe et une disposition valides." ); main_window_set_status( @@ -426,21 +468,28 @@ static void application_on_graph_loaded( application->graph_model = graph_model; + application->graph_layout = + graph_layout; + main_window_set_graph( application->main_window, - application->graph_model + application->graph_model, + application->graph_layout ); status_message = g_strdup_printf( "Graphe chargé : %" G_GUINT64_FORMAT " entité(s), %" G_GUINT64_FORMAT - " relation(s).", + " relation(s), %u position(s) restaurée(s).", investigation_graph_model_get_entity_count( application->graph_model ), investigation_graph_model_get_relation_count( application->graph_model + ), + investigation_graph_layout_get_count( + application->graph_layout ) ); @@ -4048,6 +4097,186 @@ static void application_on_verify_evidence_requested( ); } +/** + * @brief Ferme proprement l'application. + * + * @param user_data Pointeur vers Application. + */ +/** + * @brief Enregistre la nouvelle position logique d'un nœud. + * + * Le modèle en mémoire est mis à jour avant l'UPSERT SQLite. En cas d'échec + * SQLite, le déplacement reste visible pendant la session, mais un message + * explicite indique qu'il ne survivra pas à la réouverture de l'enquête. + */ +static void application_on_graph_node_moved( + const char *entity_identifier, + double x, + double y, + gpointer user_data +) +{ + Application *application = + user_data; + + Database *database = + NULL; + + GraphNodePositionDao *position_dao = + NULL; + + GError *error = + NULL; + + if (application == NULL || + application->main_window == NULL || + application->session == NULL || + application->graph_layout == NULL || + entity_identifier == NULL || + !g_uuid_string_is_valid( + entity_identifier + )) + { + return; + } + + if (!investigation_graph_layout_set_position( + application->graph_layout, + entity_identifier, + x, + y, + &error + )) + { + g_warning( + "Impossible de mettre à jour la position du nœud '%s' : %s", + entity_identifier, + error != NULL + ? error->message + : "erreur inconnue" + ); + + application_present_error( + application, + "Position invalide", + error != NULL + ? error->message + : "La position du nœud ne peut pas être conservée." + ); + + g_clear_error( + &error + ); + + return; + } + + database = + investigation_session_get_database( + application->session + ); + + if (database == NULL) + { + main_window_set_status( + application->main_window, + "Nœud déplacé, mais position non enregistrée." + ); + + application_present_error( + application, + "Enregistrement impossible", + "La session ne fournit aucune connexion SQLite." + ); + + return; + } + + position_dao = + graph_node_position_dao_new( + database, + &error + ); + + if (position_dao == NULL) + { + g_warning( + "Impossible de créer le DAO des positions : %s", + error != NULL + ? error->message + : "erreur inconnue" + ); + + main_window_set_status( + application->main_window, + "Nœud déplacé, mais position non enregistrée." + ); + + application_present_error( + application, + "Enregistrement impossible", + error != NULL + ? error->message + : "Impossible d'accéder aux positions du graphe." + ); + + g_clear_error( + &error + ); + + return; + } + + if (!graph_node_position_dao_upsert( + position_dao, + entity_identifier, + x, + y, + &error + )) + { + g_warning( + "Impossible d'enregistrer la position du nœud '%s' : %s", + entity_identifier, + error != NULL + ? error->message + : "erreur inconnue" + ); + + main_window_set_status( + application->main_window, + "Nœud déplacé, mais position non enregistrée." + ); + + application_present_error( + application, + "Enregistrement impossible", + error != NULL + ? error->message + : "La position du nœud n'a pas pu être enregistrée." + ); + + g_clear_error( + &error + ); + + graph_node_position_dao_free( + position_dao + ); + + return; + } + + graph_node_position_dao_free( + position_dao + ); + + main_window_set_status( + application->main_window, + "Position du nœud enregistrée." + ); +} + /** * @brief Ferme proprement l'application. * @@ -4138,6 +4367,12 @@ static void application_on_activate( application ); + main_window_set_graph_node_moved_callback( + application->main_window, + application_on_graph_node_moved, + application + ); + main_window_set_new_investigation_callback( application->main_window, application_on_new_investigation_requested, diff --git a/src/core/investigation_graph_load_task.c b/src/core/investigation_graph_load_task.c index 7981cf0..b7a1976 100644 --- a/src/core/investigation_graph_load_task.c +++ b/src/core/investigation_graph_load_task.c @@ -8,7 +8,10 @@ #include "core/background_task.h" #include "core/investigation_graph_loader.h" +#include "dao/graph_node_position_dao.h" #include "database/database.h" +#include "models/graph_node_position.h" +#include "models/investigation_graph_layout.h" #include #include @@ -35,6 +38,7 @@ typedef struct typedef struct { InvestigationGraphModel *graph_model; + InvestigationGraphLayout *graph_layout; } InvestigationGraphLoadResult; /** @@ -61,6 +65,8 @@ struct InvestigationGraphLoadTask gpointer user_data; GDestroyNotify user_data_destroy; + InvestigationGraphLayout *completed_layout; + gboolean running; gboolean disposed; }; @@ -484,9 +490,16 @@ static void investigation_graph_load_result_free( load_result->graph_model ); + investigation_graph_layout_free( + load_result->graph_layout + ); + load_result->graph_model = NULL; + load_result->graph_layout = + NULL; + g_free( load_result ); @@ -573,6 +586,13 @@ static void investigation_graph_load_task_destroy( ); } + investigation_graph_layout_free( + load_task->completed_layout + ); + + load_task->completed_layout = + NULL; + g_free( load_task->database_path ); @@ -685,15 +705,27 @@ static gboolean investigation_graph_load_task_worker( InvestigationGraphLoader *graph_loader = NULL; + GraphNodePositionDao *position_dao = + NULL; + + GPtrArray *positions = + NULL; + InvestigationGraphModel *graph_model = NULL; + InvestigationGraphLayout *graph_layout = + NULL; + InvestigationGraphLoadResult *load_result = NULL; GError *component_error = NULL; + guint position_index = + 0; + if (background_task == NULL || cancellable == NULL || worker_data == NULL || @@ -833,6 +865,128 @@ static gboolean investigation_graph_load_task_worker( goto failure; } + background_task_report_progress( + background_task, + 0.65, + "Chargement des positions du graphe" + ); + + position_dao = + graph_node_position_dao_new( + database, + &component_error + ); + + if (position_dao == NULL) + { + investigation_graph_load_task_set_nested_error( + error, + INVESTIGATION_GRAPH_LOAD_TASK_ERROR_LOAD, + "Impossible de préparer le chargement des positions", + component_error + ); + + goto failure; + } + + g_clear_error( + &component_error + ); + + positions = + graph_node_position_dao_list_all( + position_dao, + &component_error + ); + + if (positions == NULL) + { + investigation_graph_load_task_set_nested_error( + error, + INVESTIGATION_GRAPH_LOAD_TASK_ERROR_LOAD, + "Impossible de charger les positions du graphe", + component_error + ); + + goto failure; + } + + g_clear_error( + &component_error + ); + + graph_layout = + investigation_graph_layout_new(); + + if (graph_layout == NULL) + { + investigation_graph_load_task_set_error_literal( + error, + INVESTIGATION_GRAPH_LOAD_TASK_ERROR_MEMORY, + "Impossible d'allouer la disposition du graphe." + ); + + goto failure; + } + + for (position_index = 0; + position_index < positions->len; + position_index++) + { + const GraphNodePosition *position = + g_ptr_array_index( + positions, + position_index + ); + + if (position == NULL) + { + continue; + } + + if (!investigation_graph_layout_set_position( + graph_layout, + graph_node_position_get_entity_identifier( + position + ), + graph_node_position_get_x( + position + ), + graph_node_position_get_y( + position + ), + &component_error + )) + { + investigation_graph_load_task_set_nested_error( + error, + INVESTIGATION_GRAPH_LOAD_TASK_ERROR_LOAD, + "Impossible de construire la disposition du graphe", + component_error + ); + + goto failure; + } + + g_clear_error( + &component_error + ); + } + + g_ptr_array_unref( + positions + ); + + positions = + NULL; + + graph_node_position_dao_free( + position_dao + ); + + position_dao = + NULL; + investigation_graph_loader_free( graph_loader ); @@ -875,9 +1029,15 @@ static gboolean investigation_graph_load_task_worker( load_result->graph_model = graph_model; + load_result->graph_layout = + graph_layout; + graph_model = NULL; + graph_layout = + NULL; + if (investigation_graph_load_task_check_cancelled( cancellable, error @@ -911,6 +1071,21 @@ failure: graph_model ); + investigation_graph_layout_free( + graph_layout + ); + + if (positions != NULL) + { + g_ptr_array_unref( + positions + ); + } + + graph_node_position_dao_free( + position_dao + ); + investigation_graph_loader_free( graph_loader ); @@ -1015,6 +1190,9 @@ static void investigation_graph_load_task_on_completed( InvestigationGraphModel *graph_model = NULL; + InvestigationGraphLayout *graph_layout = + NULL; + GError *callback_error = NULL; @@ -1085,13 +1263,20 @@ static void investigation_graph_load_task_on_completed( ); if (load_result != NULL && - load_result->graph_model != NULL) + load_result->graph_model != NULL && + load_result->graph_layout != NULL) { graph_model = load_result->graph_model; + graph_layout = + load_result->graph_layout; + load_result->graph_model = NULL; + + load_result->graph_layout = + NULL; } else { @@ -1111,6 +1296,27 @@ static void investigation_graph_load_task_on_completed( ); } + if (graph_layout != NULL) + { + g_mutex_lock( + &load_task->mutex + ); + + investigation_graph_layout_free( + load_task->completed_layout + ); + + load_task->completed_layout = + graph_layout; + + graph_layout = + NULL; + + g_mutex_unlock( + &load_task->mutex + ); + } + callback( load_task, graph_model, @@ -1448,6 +1654,13 @@ gboolean investigation_graph_load_task_start( goto start_failure_locked; } + investigation_graph_layout_free( + load_task->completed_layout + ); + + load_task->completed_layout = + NULL; + load_task->background_task = background_task; @@ -1610,3 +1823,32 @@ gboolean investigation_graph_load_task_is_running( return running; } + +InvestigationGraphLayout *investigation_graph_load_task_take_layout( + InvestigationGraphLoadTask *load_task +) +{ + InvestigationGraphLayout *graph_layout = + NULL; + + if (load_task == NULL) + { + return NULL; + } + + g_mutex_lock( + &load_task->mutex + ); + + graph_layout = + load_task->completed_layout; + + load_task->completed_layout = + NULL; + + g_mutex_unlock( + &load_task->mutex + ); + + return graph_layout; +} diff --git a/src/dao/graph_node_position_dao.c b/src/dao/graph_node_position_dao.c new file mode 100644 index 0000000..933d6a6 --- /dev/null +++ b/src/dao/graph_node_position_dao.c @@ -0,0 +1,793 @@ +/****************************************************************************** + * @file graph_node_position_dao.c + * @brief Persistance des positions des nœuds du graphe dans SQLite. + ******************************************************************************/ + +#include "dao/graph_node_position_dao.h" + +#include "database/error.h" +#include "database/statement.h" + +#include +#include + +/** + * @struct GraphNodePositionDao + * @brief Représentation privée du DAO. + * + * La connexion Database est empruntée. + */ +struct GraphNodePositionDao +{ + Database *database; +}; + +/** + * @brief Requête de chargement de toutes les positions. + */ +static const char *const graph_node_position_dao_list_all_sql = + "SELECT " + " entity_id," + " x," + " y," + " updated_at " + "FROM graph_node_positions " + "ORDER BY entity_id ASC;"; + +/** + * @brief Requête d'insertion ou de mise à jour d'une position. + */ +static const char *const graph_node_position_dao_upsert_sql = + "INSERT INTO graph_node_positions" + "(" + " entity_id," + " x," + " y," + " updated_at" + ")" + "VALUES" + "(" + " ?," + " ?," + " ?," + " ?" + ")" + "ON CONFLICT(entity_id)" + "DO UPDATE SET " + " x = excluded.x," + " y = excluded.y," + " updated_at = excluded.updated_at;"; + +/** + * @brief Requête de suppression d'une position. + */ +static const char *const graph_node_position_dao_delete_sql = + "DELETE FROM graph_node_positions " + "WHERE entity_id = ?;"; + +/** + * @brief Enregistre une erreur littérale. + */ +static void graph_node_position_dao_set_error_literal( + GError **error, + GraphNodePositionDaoError error_code, + const char *error_message +) +{ + if (error == NULL) + { + return; + } + + g_set_error_literal( + error, + GRAPH_NODE_POSITION_DAO_ERROR, + error_code, + error_message + ); +} + +/** + * @brief Transforme la dernière erreur Database en erreur du DAO. + */ +static void graph_node_position_dao_set_database_error( + GraphNodePositionDao *position_dao, + GError **error, + GraphNodePositionDaoError error_code, + const char *context +) +{ + const char *database_error_message = + NULL; + + if (error == NULL) + { + return; + } + + if (position_dao != NULL && + position_dao->database != NULL) + { + database_error_message = + database_error_get_message( + position_dao->database + ); + } + + if (database_error_message != NULL && + database_error_message[0] != '\0') + { + g_set_error( + error, + GRAPH_NODE_POSITION_DAO_ERROR, + error_code, + "%s : %s", + context, + database_error_message + ); + + return; + } + + g_set_error_literal( + error, + GRAPH_NODE_POSITION_DAO_ERROR, + error_code, + context + ); +} + +/** + * @brief Génère une date UTC au format attendu par le modèle. + * + * @return Nouvelle chaîne à libérer avec g_free(), ou NULL. + */ +static char *graph_node_position_dao_create_utc_timestamp(void) +{ + GDateTime *date_time = + NULL; + + char *timestamp = + NULL; + + date_time = + g_date_time_new_now_utc(); + + if (date_time == NULL) + { + return NULL; + } + + timestamp = + g_date_time_format( + date_time, + "%Y-%m-%dT%H:%M:%SZ" + ); + + g_date_time_unref( + date_time + ); + + return timestamp; +} + +/** + * @brief Libère une position contenue dans un GPtrArray. + */ +static void graph_node_position_dao_record_destroy( + gpointer data +) +{ + graph_node_position_free( + data + ); +} + +/** + * @brief Construit une position depuis la ligne SQLite courante. + */ +static GraphNodePosition * +graph_node_position_dao_read_current_record( + GraphNodePositionDao *position_dao, + DatabaseStatement *statement, + GError **error +) +{ + GraphNodePosition *position = + NULL; + + char *entity_identifier = + NULL; + + char *updated_at = + NULL; + + double x = + 0.0; + + double y = + 0.0; + + GError *model_error = + NULL; + + if (position_dao == NULL || + position_dao->database == NULL || + statement == NULL) + { + graph_node_position_dao_set_error_literal( + error, + GRAPH_NODE_POSITION_DAO_ERROR_INVALID_ARGUMENT, + "Impossible de lire une position avec des paramètres invalides." + ); + + return NULL; + } + + if (!database_statement_column_text( + statement, + 0, + &entity_identifier + ) || + !database_statement_column_double( + statement, + 1, + &x + ) || + !database_statement_column_double( + statement, + 2, + &y + ) || + !database_statement_column_text( + statement, + 3, + &updated_at + )) + { + graph_node_position_dao_set_error_literal( + error, + GRAPH_NODE_POSITION_DAO_ERROR_READ, + "Impossible de lire les colonnes de la position du nœud." + ); + + goto cleanup; + } + + position = + graph_node_position_new( + entity_identifier, + x, + y, + updated_at, + &model_error + ); + + if (position == NULL) + { + if (model_error != NULL) + { + g_set_error( + error, + GRAPH_NODE_POSITION_DAO_ERROR, + GRAPH_NODE_POSITION_DAO_ERROR_MODEL, + "La position persistée est invalide : %s", + model_error->message + ); + } + else + { + graph_node_position_dao_set_error_literal( + error, + GRAPH_NODE_POSITION_DAO_ERROR_MODEL, + "La position persistée ne peut pas être reconstruite." + ); + } + } + +cleanup: + + g_clear_error( + &model_error + ); + + g_free( + updated_at + ); + + g_free( + entity_identifier + ); + + return position; +} + +/** + * @brief Convertit une erreur SQLite d'UPSERT en erreur du DAO. + */ +static void graph_node_position_dao_set_upsert_error( + GraphNodePositionDao *position_dao, + GError **error +) +{ + const char *database_error_message = + NULL; + + if (position_dao != NULL && + position_dao->database != NULL) + { + database_error_message = + database_error_get_message( + position_dao->database + ); + } + + if (database_error_message != NULL && + ( + strstr( + database_error_message, + "constraint" + ) != NULL || + strstr( + database_error_message, + "CONSTRAINT" + ) != NULL || + strstr( + database_error_message, + "FOREIGN KEY" + ) != NULL + )) + { + graph_node_position_dao_set_database_error( + position_dao, + error, + GRAPH_NODE_POSITION_DAO_ERROR_CONSTRAINT, + "La position viole une contrainte SQLite" + ); + + return; + } + + graph_node_position_dao_set_database_error( + position_dao, + error, + GRAPH_NODE_POSITION_DAO_ERROR_EXECUTE, + "Impossible d'enregistrer la position du nœud" + ); +} + +GQuark graph_node_position_dao_error_quark(void) +{ + return g_quark_from_static_string( + "graph-node-position-dao-error-quark" + ); +} + +GraphNodePositionDao *graph_node_position_dao_new( + Database *database, + GError **error +) +{ + GraphNodePositionDao *position_dao = + NULL; + + g_return_val_if_fail( + error == NULL || *error == NULL, + NULL + ); + + if (database == NULL) + { + graph_node_position_dao_set_error_literal( + error, + GRAPH_NODE_POSITION_DAO_ERROR_INVALID_ARGUMENT, + "La connexion Database est absente." + ); + + return NULL; + } + + position_dao = + g_try_new0( + GraphNodePositionDao, + 1 + ); + + if (position_dao == NULL) + { + graph_node_position_dao_set_error_literal( + error, + GRAPH_NODE_POSITION_DAO_ERROR_MEMORY, + "Impossible d'allouer le DAO des positions de nœuds." + ); + + return NULL; + } + + position_dao->database = + database; + + return position_dao; +} + +void graph_node_position_dao_free( + GraphNodePositionDao *position_dao +) +{ + if (position_dao == NULL) + { + return; + } + + position_dao->database = + NULL; + + g_free( + position_dao + ); +} + +GPtrArray *graph_node_position_dao_list_all( + GraphNodePositionDao *position_dao, + GError **error +) +{ + DatabaseStatement *statement = + NULL; + + GPtrArray *positions = + NULL; + + GraphNodePosition *position = + NULL; + + DatabaseStatementStepResult step_result = + DATABASE_STATEMENT_STEP_ERROR; + + g_return_val_if_fail( + error == NULL || *error == NULL, + NULL + ); + + if (position_dao == NULL || + position_dao->database == NULL) + { + graph_node_position_dao_set_error_literal( + error, + GRAPH_NODE_POSITION_DAO_ERROR_INVALID_ARGUMENT, + "Le DAO des positions de nœuds est invalide." + ); + + return NULL; + } + + positions = + g_ptr_array_new_with_free_func( + graph_node_position_dao_record_destroy + ); + + if (positions == NULL) + { + graph_node_position_dao_set_error_literal( + error, + GRAPH_NODE_POSITION_DAO_ERROR_MEMORY, + "Impossible d'allouer la liste des positions de nœuds." + ); + + return NULL; + } + + statement = + database_statement_prepare( + position_dao->database, + graph_node_position_dao_list_all_sql + ); + + if (statement == NULL) + { + graph_node_position_dao_set_database_error( + position_dao, + error, + GRAPH_NODE_POSITION_DAO_ERROR_PREPARE, + "Impossible de préparer le chargement des positions" + ); + + goto error; + } + + while (TRUE) + { + step_result = + database_statement_step( + statement + ); + + if (step_result == + DATABASE_STATEMENT_STEP_DONE) + { + break; + } + + if (step_result == + DATABASE_STATEMENT_STEP_ERROR) + { + graph_node_position_dao_set_database_error( + position_dao, + error, + GRAPH_NODE_POSITION_DAO_ERROR_EXECUTE, + "Impossible d'exécuter le chargement des positions" + ); + + goto error; + } + + position = + graph_node_position_dao_read_current_record( + position_dao, + statement, + error + ); + + if (position == NULL) + { + goto error; + } + + g_ptr_array_add( + positions, + position + ); + + /* + * Le tableau devient propriétaire du modèle. + */ + position = + NULL; + } + + database_statement_finalize( + statement + ); + + return positions; + +error: + + graph_node_position_free( + position + ); + + database_statement_finalize( + statement + ); + + g_ptr_array_unref( + positions + ); + + return NULL; +} + +gboolean graph_node_position_dao_upsert( + GraphNodePositionDao *position_dao, + const char *entity_identifier, + double x, + double y, + GError **error +) +{ + DatabaseStatement *statement = + NULL; + + char *updated_at = + NULL; + + gboolean success = + FALSE; + + g_return_val_if_fail( + error == NULL || *error == NULL, + FALSE + ); + + if (position_dao == NULL || + position_dao->database == NULL || + entity_identifier == NULL || + !g_uuid_string_is_valid( + entity_identifier + ) || + !isfinite(x) || + !isfinite(y)) + { + graph_node_position_dao_set_error_literal( + error, + GRAPH_NODE_POSITION_DAO_ERROR_INVALID_ARGUMENT, + "Les paramètres de la position du nœud sont invalides." + ); + + return FALSE; + } + + updated_at = + graph_node_position_dao_create_utc_timestamp(); + + if (updated_at == NULL) + { + graph_node_position_dao_set_error_literal( + error, + GRAPH_NODE_POSITION_DAO_ERROR_MEMORY, + "Impossible de générer la date de modification de la position." + ); + + return FALSE; + } + + statement = + database_statement_prepare( + position_dao->database, + graph_node_position_dao_upsert_sql + ); + + if (statement == NULL) + { + graph_node_position_dao_set_database_error( + position_dao, + error, + GRAPH_NODE_POSITION_DAO_ERROR_PREPARE, + "Impossible de préparer l'enregistrement de la position" + ); + + goto cleanup; + } + + if (!database_statement_bind_text( + statement, + 1, + entity_identifier + ) || + !database_statement_bind_double( + statement, + 2, + x + ) || + !database_statement_bind_double( + statement, + 3, + y + ) || + !database_statement_bind_text( + statement, + 4, + updated_at + )) + { + graph_node_position_dao_set_database_error( + position_dao, + error, + GRAPH_NODE_POSITION_DAO_ERROR_BIND, + "Impossible de lier les données de la position" + ); + + goto cleanup; + } + + if (database_statement_step( + statement + ) != DATABASE_STATEMENT_STEP_DONE) + { + graph_node_position_dao_set_upsert_error( + position_dao, + error + ); + + goto cleanup; + } + + success = + TRUE; + +cleanup: + + database_statement_finalize( + statement + ); + + g_free( + updated_at + ); + + return success; +} + +gboolean graph_node_position_dao_delete( + GraphNodePositionDao *position_dao, + const char *entity_identifier, + GError **error +) +{ + DatabaseStatement *statement = + NULL; + + gboolean success = + FALSE; + + g_return_val_if_fail( + error == NULL || *error == NULL, + FALSE + ); + + if (position_dao == NULL || + position_dao->database == NULL || + entity_identifier == NULL || + !g_uuid_string_is_valid( + entity_identifier + )) + { + graph_node_position_dao_set_error_literal( + error, + GRAPH_NODE_POSITION_DAO_ERROR_INVALID_ARGUMENT, + "L'identifiant de l'entité est invalide." + ); + + return FALSE; + } + + statement = + database_statement_prepare( + position_dao->database, + graph_node_position_dao_delete_sql + ); + + if (statement == NULL) + { + graph_node_position_dao_set_database_error( + position_dao, + error, + GRAPH_NODE_POSITION_DAO_ERROR_PREPARE, + "Impossible de préparer la suppression de la position" + ); + + goto cleanup; + } + + if (!database_statement_bind_text( + statement, + 1, + entity_identifier + )) + { + graph_node_position_dao_set_database_error( + position_dao, + error, + GRAPH_NODE_POSITION_DAO_ERROR_BIND, + "Impossible de lier l'identifiant de l'entité" + ); + + goto cleanup; + } + + if (database_statement_step( + statement + ) != DATABASE_STATEMENT_STEP_DONE) + { + graph_node_position_dao_set_database_error( + position_dao, + error, + GRAPH_NODE_POSITION_DAO_ERROR_EXECUTE, + "Impossible de supprimer la position du nœud" + ); + + goto cleanup; + } + + success = + TRUE; + +cleanup: + + database_statement_finalize( + statement + ); + + return success; +} diff --git a/src/database/database.c b/src/database/database.c index 6ed7217..e05c946 100644 --- a/src/database/database.c +++ b/src/database/database.c @@ -668,6 +668,65 @@ rollback: return false; } +/** + * @brief Garantit atomiquement la présence des extensions du schéma courant. + */ +static bool database_ensure_current_schema( + Database *database +) +{ + bool transaction_started = false; + + if (database == NULL) + { + return false; + } + + if (!database_transaction_begin( + database + )) + { + return false; + } + + transaction_started = true; + + if (!schema_ensure_current( + database + )) + { + goto rollback; + } + + if (!database_transaction_commit( + database + )) + { + goto rollback; + } + + transaction_started = false; + + return true; + +rollback: + + if (transaction_started) + { + if (!database_transaction_rollback( + database + )) + { + g_warning( + "Impossible d’annuler l’installation des extensions " + "du schéma SQLite courant." + ); + } + } + + return false; +} + Database *database_open( const char *database_path ) @@ -807,6 +866,13 @@ bool database_migrate_to_latest( } } + if (!database_ensure_current_schema( + database + )) + { + return false; + } + database->schema_version = schema_version; @@ -897,6 +963,13 @@ bool database_initialize( goto rollback; } + if (!schema_ensure_current( + database + )) + { + goto rollback; + } + if (!database_insert_all_metadata( database, created_at, diff --git a/src/database/schema.c b/src/database/schema.c index 77b2e20..0302c22 100644 --- a/src/database/schema.c +++ b/src/database/schema.c @@ -192,3 +192,14 @@ bool schema_install_v2( "la migration SQLite V2" ); } + +bool schema_ensure_current( + Database *database +) +{ + return schema_execute_file( + database, + "database/schema_current.sql", + "les extensions du schéma SQLite courant" + ); +} diff --git a/src/database/statement.c b/src/database/statement.c index 5825b97..6db1b91 100644 --- a/src/database/statement.c +++ b/src/database/statement.c @@ -238,6 +238,79 @@ bool database_statement_bind_int64( return true; } +bool database_statement_bind_double( + DatabaseStatement *statement, + int index, + double value +) +{ + sqlite3 *database_handle = + NULL; + + const char *error_message = + NULL; + + int result = + SQLITE_ERROR; + + if (statement == NULL) + { + return false; + } + + if (statement->handle == NULL || + index <= 0) + { + database_set_error( + statement->database, + DATABASE_ERROR_INVALID_ARGUMENT, + "Paramètres invalides pour la liaison d'un nombre réel." + ); + + return false; + } + + result = + sqlite3_bind_double( + statement->handle, + index, + value + ); + + if (result != SQLITE_OK) + { + database_handle = + database_get_handle( + statement->database + ); + + error_message = + database_handle != NULL + ? sqlite3_errmsg(database_handle) + : sqlite3_errstr(result); + + database_set_error( + statement->database, + DATABASE_ERROR_SQLITE, + error_message + ); + + g_warning( + "Impossible de lier le paramètre réel %d : %s", + index, + error_message + ); + + return false; + } + + database_clear_error_internal( + statement->database + ); + + return true; +} + bool database_statement_bind_null( DatabaseStatement *statement, int index @@ -477,6 +550,57 @@ bool database_statement_column_is_null( return true; } +bool database_statement_column_double( + DatabaseStatement *statement, + int column_index, + double *value +) +{ + int column_count = + 0; + + int column_type = + SQLITE_NULL; + + if (statement == NULL || + statement->handle == NULL || + value == NULL) + { + return false; + } + + column_count = + sqlite3_column_count( + statement->handle + ); + + if (column_index < 0 || + column_index >= column_count) + { + return false; + } + + column_type = + sqlite3_column_type( + statement->handle, + column_index + ); + + if (column_type != SQLITE_FLOAT && + column_type != SQLITE_INTEGER) + { + return false; + } + + *value = + sqlite3_column_double( + statement->handle, + column_index + ); + + return true; +} + bool database_statement_column_text( DatabaseStatement *statement, int column_index, diff --git a/src/models/graph_node_position.c b/src/models/graph_node_position.c new file mode 100644 index 0000000..9b6671e --- /dev/null +++ b/src/models/graph_node_position.c @@ -0,0 +1,336 @@ +/****************************************************************************** + * @file graph_node_position.c + * @brief Implémentation du modèle de position persistée d'un nœud. + ******************************************************************************/ + +#include "models/graph_node_position.h" + +#include +#include + +/** + * @struct GraphNodePosition + * @brief Représentation privée d'une position de nœud. + */ +struct GraphNodePosition +{ + char *entity_identifier; + + double x; + double y; + + char *updated_at; +}; + +/** + * @brief Copie et nettoie une chaîne. + */ +static char *graph_node_position_duplicate_trimmed( + const char *value +) +{ + char *copy = + NULL; + + if (value == NULL) + { + return NULL; + } + + copy = + g_strdup( + value + ); + + if (copy == NULL) + { + return NULL; + } + + g_strstrip( + copy + ); + + return copy; +} + +/** + * @brief Vérifie une date UTC stricte YYYY-MM-DDTHH:MM:SSZ. + */ +static gboolean graph_node_position_timestamp_is_valid( + const char *timestamp +) +{ + GDateTime *date_time = + NULL; + + char *normalized_timestamp = + NULL; + + gboolean valid = + FALSE; + + gsize character_index = + 0; + + if (timestamp == NULL || + strlen(timestamp) != 20U) + { + return FALSE; + } + + if (timestamp[4] != '-' || + timestamp[7] != '-' || + timestamp[10] != 'T' || + timestamp[13] != ':' || + timestamp[16] != ':' || + timestamp[19] != 'Z') + { + return FALSE; + } + + for (character_index = 0; + character_index < 20U; + character_index++) + { + if (character_index == 4U || + character_index == 7U || + character_index == 10U || + character_index == 13U || + character_index == 16U || + character_index == 19U) + { + continue; + } + + if (!g_ascii_isdigit( + timestamp[character_index] + )) + { + return FALSE; + } + } + + date_time = + g_date_time_new_from_iso8601( + timestamp, + NULL + ); + + if (date_time == NULL) + { + return FALSE; + } + + normalized_timestamp = + g_date_time_format( + date_time, + "%Y-%m-%dT%H:%M:%SZ" + ); + + valid = + normalized_timestamp != NULL && + g_strcmp0( + normalized_timestamp, + timestamp + ) == 0; + + g_free( + normalized_timestamp + ); + + g_date_time_unref( + date_time + ); + + return valid; +} + +GQuark graph_node_position_error_quark(void) +{ + return g_quark_from_static_string( + "graph-node-position-error-quark" + ); +} + +GraphNodePosition *graph_node_position_new( + const char *entity_identifier, + double x, + double y, + const char *updated_at, + GError **error +) +{ + GraphNodePosition *position = + NULL; + + char *entity_identifier_copy = + NULL; + + char *updated_at_copy = + NULL; + + g_return_val_if_fail( + error == NULL || *error == NULL, + NULL + ); + + entity_identifier_copy = + graph_node_position_duplicate_trimmed( + entity_identifier + ); + + if (entity_identifier_copy == NULL || + entity_identifier_copy[0] == '\0' || + !g_uuid_string_is_valid( + entity_identifier_copy + )) + { + g_set_error_literal( + error, + GRAPH_NODE_POSITION_ERROR, + GRAPH_NODE_POSITION_ERROR_INVALID_IDENTIFIER, + "L'identifiant de l'entité n'est pas un UUID valide." + ); + + goto cleanup; + } + + if (!isfinite(x) || + !isfinite(y)) + { + g_set_error_literal( + error, + GRAPH_NODE_POSITION_ERROR, + GRAPH_NODE_POSITION_ERROR_INVALID_COORDINATE, + "Les coordonnées du nœud doivent être des nombres finis." + ); + + goto cleanup; + } + + updated_at_copy = + graph_node_position_duplicate_trimmed( + updated_at + ); + + if (!graph_node_position_timestamp_is_valid( + updated_at_copy + )) + { + g_set_error_literal( + error, + GRAPH_NODE_POSITION_ERROR, + GRAPH_NODE_POSITION_ERROR_INVALID_DATE, + "La date de modification de la position est invalide." + ); + + goto cleanup; + } + + position = + g_try_new0( + GraphNodePosition, + 1 + ); + + if (position == NULL) + { + g_set_error_literal( + error, + GRAPH_NODE_POSITION_ERROR, + GRAPH_NODE_POSITION_ERROR_INVALID_ARGUMENT, + "Impossible d'allouer la position du nœud." + ); + + goto cleanup; + } + + position->entity_identifier = + entity_identifier_copy; + + position->x = + x; + + position->y = + y; + + position->updated_at = + updated_at_copy; + + entity_identifier_copy = + NULL; + + updated_at_copy = + NULL; + +cleanup: + + g_free( + updated_at_copy + ); + + g_free( + entity_identifier_copy + ); + + return position; +} + +void graph_node_position_free( + GraphNodePosition *position +) +{ + if (position == NULL) + { + return; + } + + g_free( + position->updated_at + ); + + g_free( + position->entity_identifier + ); + + g_free( + position + ); +} + +const char *graph_node_position_get_entity_identifier( + const GraphNodePosition *position +) +{ + return position != NULL + ? position->entity_identifier + : NULL; +} + +double graph_node_position_get_x( + const GraphNodePosition *position +) +{ + return position != NULL + ? position->x + : 0.0; +} + +double graph_node_position_get_y( + const GraphNodePosition *position +) +{ + return position != NULL + ? position->y + : 0.0; +} + +const char *graph_node_position_get_updated_at( + const GraphNodePosition *position +) +{ + return position != NULL + ? position->updated_at + : NULL; +} diff --git a/src/models/investigation_graph_layout.c b/src/models/investigation_graph_layout.c new file mode 100644 index 0000000..2072e0f --- /dev/null +++ b/src/models/investigation_graph_layout.c @@ -0,0 +1,321 @@ +/****************************************************************************** + * @file investigation_graph_layout.c + * @brief Implémentation du modèle en mémoire de disposition du graphe. + ******************************************************************************/ + +#include "models/investigation_graph_layout.h" + +#include + +/** + * @brief Coordonnées privées associées à une entité. + */ +typedef struct +{ + double x; + double y; +} InvestigationGraphLayoutPosition; + +/** + * @struct InvestigationGraphLayout + * @brief Représentation privée de la disposition. + */ +struct InvestigationGraphLayout +{ + GHashTable *positions_by_entity_identifier; +}; + +/** + * @brief Enregistre une erreur littérale. + */ +static void investigation_graph_layout_set_error_literal( + GError **error, + InvestigationGraphLayoutError error_code, + const char *error_message +) +{ + if (error == NULL) + { + return; + } + + g_set_error_literal( + error, + INVESTIGATION_GRAPH_LAYOUT_ERROR, + error_code, + error_message + ); +} + +/** + * @brief Vérifie un identifiant d'entité. + */ +static gboolean investigation_graph_layout_identifier_is_valid( + const char *entity_identifier +) +{ + return entity_identifier != NULL && + entity_identifier[0] != '\0' && + g_uuid_string_is_valid( + entity_identifier + ); +} + +GQuark investigation_graph_layout_error_quark(void) +{ + return g_quark_from_static_string( + "investigation-graph-layout-error-quark" + ); +} + +InvestigationGraphLayout *investigation_graph_layout_new(void) +{ + InvestigationGraphLayout *layout = + NULL; + + layout = + g_try_new0( + InvestigationGraphLayout, + 1 + ); + + if (layout == NULL) + { + return NULL; + } + + layout->positions_by_entity_identifier = + g_hash_table_new_full( + g_str_hash, + g_str_equal, + g_free, + g_free + ); + + if (layout->positions_by_entity_identifier == NULL) + { + investigation_graph_layout_free( + layout + ); + + return NULL; + } + + return layout; +} + +gboolean investigation_graph_layout_set_position( + InvestigationGraphLayout *layout, + const char *entity_identifier, + double x, + double y, + GError **error +) +{ + InvestigationGraphLayoutPosition *position = + NULL; + + char *entity_identifier_copy = + NULL; + + g_return_val_if_fail( + error == NULL || *error == NULL, + FALSE + ); + + if (layout == NULL || + layout->positions_by_entity_identifier == NULL) + { + investigation_graph_layout_set_error_literal( + error, + INVESTIGATION_GRAPH_LAYOUT_ERROR_INVALID_ARGUMENT, + "La disposition du graphe est absente." + ); + + return FALSE; + } + + if (!investigation_graph_layout_identifier_is_valid( + entity_identifier + )) + { + investigation_graph_layout_set_error_literal( + error, + INVESTIGATION_GRAPH_LAYOUT_ERROR_INVALID_IDENTIFIER, + "L'identifiant de l'entité n'est pas un UUID valide." + ); + + return FALSE; + } + + if (!isfinite(x) || + !isfinite(y)) + { + investigation_graph_layout_set_error_literal( + error, + INVESTIGATION_GRAPH_LAYOUT_ERROR_INVALID_COORDINATE, + "Les coordonnées du nœud doivent être des nombres finis." + ); + + return FALSE; + } + + entity_identifier_copy = + g_strdup( + entity_identifier + ); + + position = + g_try_new0( + InvestigationGraphLayoutPosition, + 1 + ); + + if (entity_identifier_copy == NULL || + position == NULL) + { + g_free( + position + ); + + g_free( + entity_identifier_copy + ); + + investigation_graph_layout_set_error_literal( + error, + INVESTIGATION_GRAPH_LAYOUT_ERROR_MEMORY, + "Impossible d'allouer la position du nœud." + ); + + return FALSE; + } + + position->x = + x; + + position->y = + y; + + g_hash_table_replace( + layout->positions_by_entity_identifier, + entity_identifier_copy, + position + ); + + return TRUE; +} + +gboolean investigation_graph_layout_get_position( + const InvestigationGraphLayout *layout, + const char *entity_identifier, + double *x, + double *y +) +{ + const InvestigationGraphLayoutPosition *position = + NULL; + + if (layout == NULL || + layout->positions_by_entity_identifier == NULL || + !investigation_graph_layout_identifier_is_valid( + entity_identifier + )) + { + return FALSE; + } + + position = + g_hash_table_lookup( + layout->positions_by_entity_identifier, + entity_identifier + ); + + if (position == NULL) + { + return FALSE; + } + + if (x != NULL) + { + *x = + position->x; + } + + if (y != NULL) + { + *y = + position->y; + } + + return TRUE; +} + +gboolean investigation_graph_layout_remove_position( + InvestigationGraphLayout *layout, + const char *entity_identifier +) +{ + if (layout == NULL || + layout->positions_by_entity_identifier == NULL || + !investigation_graph_layout_identifier_is_valid( + entity_identifier + )) + { + return FALSE; + } + + return g_hash_table_remove( + layout->positions_by_entity_identifier, + entity_identifier + ); +} + +void investigation_graph_layout_clear( + InvestigationGraphLayout *layout +) +{ + if (layout == NULL || + layout->positions_by_entity_identifier == NULL) + { + return; + } + + g_hash_table_remove_all( + layout->positions_by_entity_identifier + ); +} + +guint investigation_graph_layout_get_count( + const InvestigationGraphLayout *layout +) +{ + if (layout == NULL || + layout->positions_by_entity_identifier == NULL) + { + return 0U; + } + + return g_hash_table_size( + layout->positions_by_entity_identifier + ); +} + +void investigation_graph_layout_free( + InvestigationGraphLayout *layout +) +{ + if (layout == NULL) + { + return; + } + + g_clear_pointer( + &layout->positions_by_entity_identifier, + g_hash_table_unref + ); + + g_free( + layout + ); +} diff --git a/src/views/main_window.c b/src/views/main_window.c index f20c063..cfc9f15 100644 --- a/src/views/main_window.c +++ b/src/views/main_window.c @@ -92,6 +92,12 @@ struct MainWindow gpointer verify_evidence_user_data; + MainWindowGraphNodeMovedCallback + graph_node_moved_callback; + + gpointer + graph_node_moved_user_data; + MainWindowQuitCallback quit_callback; @@ -252,6 +258,35 @@ static GtkWidget *main_window_create_action_button( return button; } +/** + * @brief Relaie la demande de vérification provenant du Workspace. + */ +static void main_window_on_graph_node_moved( + const char *entity_identifier, + double x, + double y, + gpointer user_data +) +{ + MainWindow *main_window = + user_data; + + if (main_window == NULL || + main_window->graph_node_moved_callback == NULL || + entity_identifier == NULL || + entity_identifier[0] == '\0') + { + return; + } + + main_window->graph_node_moved_callback( + entity_identifier, + x, + y, + main_window->graph_node_moved_user_data + ); +} + /** * @brief Relaie la demande de vérification provenant du Workspace. */ @@ -515,6 +550,12 @@ MainWindow *main_window_new( main_window ); + workspace_set_graph_node_moved_callback( + main_window->workspace, + main_window_on_graph_node_moved, + main_window + ); + workspace_widget = workspace_get_widget( main_window->workspace ); @@ -870,7 +911,8 @@ void main_window_set_graph_loading( void main_window_set_graph( MainWindow *main_window, - const InvestigationGraphModel *graph_model + const InvestigationGraphModel *graph_model, + const InvestigationGraphLayout *graph_layout ) { if (main_window == NULL) @@ -880,7 +922,8 @@ void main_window_set_graph( workspace_set_graph( main_window->workspace, - graph_model + graph_model, + graph_layout ); } @@ -1058,6 +1101,24 @@ void main_window_set_verify_evidence_callback( user_data; } +void main_window_set_graph_node_moved_callback( + MainWindow *main_window, + MainWindowGraphNodeMovedCallback callback, + gpointer user_data +) +{ + if (main_window == NULL) + { + return; + } + + main_window->graph_node_moved_callback = + callback; + + main_window->graph_node_moved_user_data = + user_data; +} + void main_window_set_quit_callback( MainWindow *main_window, MainWindowQuitCallback callback, @@ -1174,6 +1235,18 @@ void main_window_free( NULL ); + workspace_set_graph_node_moved_callback( + main_window->workspace, + NULL, + NULL + ); + + main_window->graph_node_moved_callback = + NULL; + + main_window->graph_node_moved_user_data = + NULL; + main_window_clear_graph( main_window ); diff --git a/src/widgets/investigation_graph_view.c b/src/widgets/investigation_graph_view.c index f4d0811..8cbff66 100644 --- a/src/widgets/investigation_graph_view.c +++ b/src/widgets/investigation_graph_view.c @@ -6,6 +6,7 @@ #include "widgets/investigation_graph_view.h" #include "models/entity_record.h" +#include "models/investigation_graph_layout.h" #include "models/investigation_graph_model.h" #include "models/relation_record.h" @@ -57,6 +58,7 @@ struct InvestigationGraphView GtkEventController *motion_controller; const InvestigationGraphModel *graph_model; + const InvestigationGraphLayout *graph_layout; GPtrArray *node_layouts; GHashTable *node_layouts_by_identifier; @@ -92,6 +94,9 @@ struct InvestigationGraphView InvestigationGraphViewSelectionCallback selection_callback; gpointer selection_user_data; + InvestigationGraphViewNodeMovedCallback node_moved_callback; + gpointer node_moved_user_data; + double node_drag_pointer_offset_x; double node_drag_pointer_offset_y; @@ -1042,6 +1047,44 @@ static void investigation_graph_view_on_drag_update( ); } +/** + * @brief Prévient l'appelant qu'un nœud a été déplacé. + */ +static void investigation_graph_view_notify_node_moved( + InvestigationGraphView *graph_view, + const InvestigationGraphNodeLayout *node_layout +) +{ + const char *entity_identifier = + NULL; + + if (graph_view == NULL || + node_layout == NULL || + node_layout->entity_record == NULL || + graph_view->node_moved_callback == NULL) + { + return; + } + + entity_identifier = + entity_record_get_identifier( + node_layout->entity_record + ); + + if (entity_identifier == NULL || + entity_identifier[0] == '\0') + { + return; + } + + graph_view->node_moved_callback( + entity_identifier, + node_layout->x, + node_layout->y, + graph_view->node_moved_user_data + ); +} + /** * @brief Termine le déplacement actif. */ @@ -1067,6 +1110,12 @@ static void investigation_graph_view_on_drag_end( double logical_y = 0.0; + InvestigationGraphNodeLayout *moved_node = + NULL; + + gboolean node_was_moved = + FALSE; + (void) gesture; if (graph_view == NULL || @@ -1088,6 +1137,19 @@ static void investigation_graph_view_on_drag_end( else if (graph_view->node_dragging && graph_view->dragged_node != NULL) { + moved_node = + graph_view->dragged_node; + + node_was_moved = + ( + (offset_x * offset_x) + + (offset_y * offset_y) + ) > + ( + INVESTIGATION_GRAPH_VIEW_CLICK_TOLERANCE * + INVESTIGATION_GRAPH_VIEW_CLICK_TOLERANCE + ); + pointer_x = graph_view->drag_start_x + offset_x; @@ -1118,6 +1180,15 @@ static void investigation_graph_view_on_drag_end( graph_view ); + if (node_was_moved && + moved_node != NULL) + { + investigation_graph_view_notify_node_moved( + graph_view, + moved_node + ); + } + gtk_widget_queue_draw( graph_view->drawing_area ); @@ -1276,7 +1347,9 @@ static GPtrArray *investigation_graph_view_list_active_entities( */ static GPtrArray *investigation_graph_view_create_layout( const GPtrArray *active_entities, - int width + const InvestigationGraphLayout *graph_layout, + int width, + gboolean apply_persisted_positions ) { GPtrArray *node_layouts = @@ -1361,6 +1434,15 @@ static GPtrArray *investigation_graph_view_create_layout( return NULL; } + const char *entity_identifier = + NULL; + + double persisted_x = + 0.0; + + double persisted_y = + 0.0; + node_layout->entity_record = g_ptr_array_index( active_entities, @@ -1377,6 +1459,29 @@ static GPtrArray *investigation_graph_view_create_layout( (INVESTIGATION_GRAPH_VIEW_NODE_HEIGHT + INVESTIGATION_GRAPH_VIEW_VERTICAL_GAP)); + entity_identifier = + entity_record_get_identifier( + node_layout->entity_record + ); + + if (apply_persisted_positions && + graph_layout != NULL && + entity_identifier != NULL && + entity_identifier[0] != '\0' && + investigation_graph_layout_get_position( + graph_layout, + entity_identifier, + &persisted_x, + &persisted_y + )) + { + node_layout->x = + persisted_x; + + node_layout->y = + persisted_y; + } + g_ptr_array_add( node_layouts, node_layout @@ -1540,6 +1645,7 @@ static void investigation_graph_view_set_layout_error( static gboolean investigation_graph_view_build_layout( InvestigationGraphView *graph_view, int width, + gboolean apply_persisted_positions, GError **error ) { @@ -1597,7 +1703,9 @@ static gboolean investigation_graph_view_build_layout( node_layouts = investigation_graph_view_create_layout( active_entities, - width + graph_view->graph_layout, + width, + apply_persisted_positions ); if (node_layouts == NULL) @@ -3146,6 +3254,73 @@ GtkWidget *investigation_graph_view_get_widget( return graph_view->drawing_area; } +/** + * @brief Reconstruit la disposition privée du graphe affiché. + * + * Le zoom et le déplacement global du canvas sont conservés. + */ +static void investigation_graph_view_rebuild_current_layout( + InvestigationGraphView *graph_view, + gboolean apply_persisted_positions +) +{ + GError *error = + NULL; + + int width = + 640; + + if (graph_view == NULL || + graph_view->graph_model == NULL) + { + return; + } + + investigation_graph_view_clear_layout( + graph_view + ); + + if (graph_view->drawing_area != NULL) + { + width = + gtk_widget_get_width( + graph_view->drawing_area + ); + + if (width <= 0) + { + width = + 640; + } + } + + if (!investigation_graph_view_build_layout( + graph_view, + width, + apply_persisted_positions, + &error + )) + { + graph_view->layout_error_message = + g_strdup( + error != NULL + ? error->message + : "Impossible de reconstruire la disposition du graphe." + ); + } + + g_clear_error( + &error + ); + + if (graph_view->drawing_area != NULL) + { + gtk_widget_queue_draw( + graph_view->drawing_area + ); + } +} + void investigation_graph_view_set_graph( InvestigationGraphView *graph_view, const InvestigationGraphModel *graph_model @@ -3195,6 +3370,7 @@ void investigation_graph_view_set_graph( if (!investigation_graph_view_build_layout( graph_view, width, + TRUE, &error )) { @@ -3215,6 +3391,25 @@ void investigation_graph_view_set_graph( ); } +void investigation_graph_view_set_layout( + InvestigationGraphView *graph_view, + const InvestigationGraphLayout *graph_layout +) +{ + if (graph_view == NULL) + { + return; + } + + graph_view->graph_layout = + graph_layout; + + investigation_graph_view_rebuild_current_layout( + graph_view, + TRUE + ); +} + void investigation_graph_view_clear( InvestigationGraphView *graph_view ) @@ -3231,6 +3426,9 @@ void investigation_graph_view_clear( graph_view->graph_model = NULL; + graph_view->graph_layout = + NULL; + investigation_graph_view_reset_view( graph_view ); @@ -3254,6 +3452,24 @@ void investigation_graph_view_set_selection_callback( user_data; } +void investigation_graph_view_set_node_moved_callback( + InvestigationGraphView *graph_view, + InvestigationGraphViewNodeMovedCallback callback, + gpointer user_data +) +{ + if (graph_view == NULL) + { + return; + } + + graph_view->node_moved_callback = + callback; + + graph_view->node_moved_user_data = + user_data; +} + const EntityRecord *investigation_graph_view_get_selected_entity( const InvestigationGraphView *graph_view ) @@ -3377,6 +3593,7 @@ void investigation_graph_view_reset_layout( if (!investigation_graph_view_build_layout( graph_view, width, + FALSE, &error )) { @@ -3416,6 +3633,12 @@ void investigation_graph_view_free( graph_view->selection_user_data = NULL; + graph_view->node_moved_callback = + NULL; + + graph_view->node_moved_user_data = + NULL; + investigation_graph_view_clear_layout( graph_view ); @@ -3423,6 +3646,9 @@ void investigation_graph_view_free( graph_view->graph_model = NULL; + graph_view->graph_layout = + NULL; + if (graph_view->drawing_area != NULL) { gtk_drawing_area_set_draw_func( diff --git a/src/widgets/workspace.c b/src/widgets/workspace.c index 320474e..7f0e7e9 100644 --- a/src/widgets/workspace.c +++ b/src/widgets/workspace.c @@ -77,6 +77,12 @@ struct Workspace gpointer verify_evidence_user_data; + WorkspaceGraphNodeMovedCallback + graph_node_moved_callback; + + gpointer + graph_node_moved_user_data; + GtkWidget *node_name_label; GtkWidget *node_path_label; GtkWidget *node_type_label; @@ -84,6 +90,8 @@ struct Workspace GtkWidget *node_children_label; const InvestigationGraphModel *graph_model; + const InvestigationGraphLayout *graph_layout; + WorkspaceGraphState graph_state; }; @@ -254,6 +262,35 @@ static const char *workspace_get_integrity_status_text( } } +/** + * @brief Transmet la sélection du graphe au volet de détails. + */ +static void workspace_on_graph_node_moved( + const char *entity_identifier, + double x, + double y, + gpointer user_data +) +{ + Workspace *workspace = + user_data; + + if (workspace == NULL || + workspace->graph_node_moved_callback == NULL || + entity_identifier == NULL || + entity_identifier[0] == '\0') + { + return; + } + + workspace->graph_node_moved_callback( + entity_identifier, + x, + y, + workspace->graph_node_moved_user_data + ); +} + /** * @brief Transmet la sélection du graphe au volet de détails. */ @@ -990,6 +1027,12 @@ Workspace *workspace_new(void) workspace ); + investigation_graph_view_set_node_moved_callback( + workspace->graph_view, + workspace_on_graph_node_moved, + workspace + ); + entity_details_panel_set_close_callback( workspace->entity_details_panel, workspace_on_entity_details_closed, @@ -1033,6 +1076,9 @@ Workspace *workspace_new(void) workspace->graph_model = NULL; + workspace->graph_layout = + NULL; + workspace->graph_state = WORKSPACE_GRAPH_STATE_EMPTY; @@ -1406,6 +1452,9 @@ void workspace_set_graph_loading( workspace->graph_model = NULL; + workspace->graph_layout = + NULL; + workspace->graph_state = WORKSPACE_GRAPH_STATE_LOADING; @@ -1441,7 +1490,8 @@ void workspace_set_graph_loading( void workspace_set_graph( Workspace *workspace, - const InvestigationGraphModel *graph_model + const InvestigationGraphModel *graph_model, + const InvestigationGraphLayout *graph_layout ) { if (workspace == NULL) @@ -1475,9 +1525,21 @@ void workspace_set_graph( workspace->entity_details_panel ); + investigation_graph_view_clear( + workspace->graph_view + ); + workspace->graph_model = graph_model; + workspace->graph_layout = + graph_layout; + + investigation_graph_view_set_layout( + workspace->graph_view, + graph_layout + ); + investigation_graph_view_set_graph( workspace->graph_view, graph_model @@ -1550,6 +1612,9 @@ void workspace_set_graph_error( workspace->graph_model = NULL; + workspace->graph_layout = + NULL; + workspace->graph_state = WORKSPACE_GRAPH_STATE_ERROR; @@ -1619,6 +1684,9 @@ void workspace_clear_graph( workspace->graph_model = NULL; + workspace->graph_layout = + NULL; + workspace->graph_state = WORKSPACE_GRAPH_STATE_EMPTY; @@ -1670,6 +1738,24 @@ void workspace_set_verify_evidence_callback( user_data; } +void workspace_set_graph_node_moved_callback( + Workspace *workspace, + WorkspaceGraphNodeMovedCallback callback, + gpointer user_data +) +{ + if (workspace == NULL) + { + return; + } + + workspace->graph_node_moved_callback = + callback; + + workspace->graph_node_moved_user_data = + user_data; +} + void workspace_free(Workspace *workspace) { if (workspace == NULL) @@ -1683,6 +1769,12 @@ void workspace_free(Workspace *workspace) NULL ); + investigation_graph_view_set_node_moved_callback( + workspace->graph_view, + NULL, + NULL + ); + entity_details_panel_set_close_callback( workspace->entity_details_panel, NULL, @@ -1714,12 +1806,21 @@ void workspace_free(Workspace *workspace) workspace->graph_model = NULL; + workspace->graph_layout = + NULL; + workspace->verify_evidence_callback = NULL; workspace->verify_evidence_user_data = NULL; + workspace->graph_node_moved_callback = + NULL; + + workspace->graph_node_moved_user_data = + NULL; + g_clear_pointer( &workspace->selected_evidence_identifier, g_free