From 205c65f275739d8427a4a2e286fcd40e5f94f5de Mon Sep 17 00:00:00 2001 From: grayTerminal-sh Date: Wed, 22 Jul 2026 18:29:34 +0200 Subject: [PATCH] feat(person): add investigation roles and graph colors --- README.md | 2 + database/schema_current.sql | 2 +- database/schema_v5.sql | 30 +++++++++++++ docs/database/DATABASE_ARCHITECTURE.md | 11 +++++ include/core/person_entity_service.h | 4 ++ include/database/schema.h | 3 ++ include/models/entity_record.h | 23 ++++++++++ include/views/main_window.h | 11 +++++ include/widgets/entity_details_panel.h | 15 +++++++ include/widgets/workspace.h | 9 ++++ src/core/application.c | 40 +++++++++++++++++ src/core/person_entity_service.c | 53 ++++++++++++++++++++++ src/dao/entity_dao.c | 23 +++++++++- src/database/database.c | 33 +++++++++++++- src/database/schema.c | 6 +++ src/models/entity_record.c | 60 +++++++++++++++++++++++++ src/views/main_window.c | 33 ++++++++++++++ src/widgets/entity_details_panel.c | 62 ++++++++++++++++++++++++++ src/widgets/investigation_graph_view.c | 28 +++++++++++- src/widgets/workspace.c | 26 +++++++++++ tests/test_database.c | 5 ++- tests/test_person_entity_service.c | 7 +++ 22 files changed, 478 insertions(+), 8 deletions(-) create mode 100644 database/schema_v5.sql diff --git a/README.md b/README.md index caf2057..eb3cc2f 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,8 @@ Le socle actuel comprend notamment : conservant leur lisibilité pendant le zoom ; - création de personnes observées avec statut d'identification, confiance, notes factuelles et rattachement facultatif à une preuve ; +- catégories d'enquête des personnes en SQLite V5, modifiables depuis leur + fiche et représentées par une couleur et un libellé dans le graphe ; - relations représentées par des flèches directes à libellé cliquable, sans ajouter de faux nœud visuel entre les entités ; - historique OSINT contextuel en lecture seule avec détail des exécutions, diff --git a/database/schema_current.sql b/database/schema_current.sql index 630d153..c46f352 100644 --- a/database/schema_current.sql +++ b/database/schema_current.sql @@ -1,7 +1,7 @@ /****************************************************************************** * Labfy Investigation * - * Extensions idempotentes du schéma SQLite courant V4 + * Extensions idempotentes du schéma SQLite courant V5 ******************************************************************************/ /* diff --git a/database/schema_v5.sql b/database/schema_v5.sql new file mode 100644 index 0000000..dad9894 --- /dev/null +++ b/database/schema_v5.sql @@ -0,0 +1,30 @@ +/****************************************************************************** + * Labfy Investigation + * + * Migration du schéma SQLite V4 vers V5 : rôles d'enquête des personnes + ******************************************************************************/ + +CREATE TABLE person_roles +( + entity_id TEXT PRIMARY KEY, + role TEXT NOT NULL DEFAULT 'uncategorized', + updated_at TEXT NOT NULL, + + FOREIGN KEY (entity_id) + REFERENCES entites(id) + ON UPDATE CASCADE + ON DELETE CASCADE, + + CHECK (role IN + ( + 'uncategorized', + 'alleged_scammer', + 'victim', + 'witness', + 'suspect', + 'related_person' + )), + CHECK (length(updated_at) = 20) +); + +CREATE INDEX idx_person_roles_role ON person_roles(role); diff --git a/docs/database/DATABASE_ARCHITECTURE.md b/docs/database/DATABASE_ARCHITECTURE.md index aa0d9aa..5511649 100644 --- a/docs/database/DATABASE_ARCHITECTURE.md +++ b/docs/database/DATABASE_ARCHITECTURE.md @@ -1161,6 +1161,17 @@ compte via `preuve_entites`. La création des deux lignes et de cette liaison est transactionnelle : aucun nœud incomplet n'est conservé si une étape échoue. +### Rôles d'enquête des personnes — schéma V5 + +La table `person_roles` associe une entité de type personne à une catégorie +d'enquête contrôlée : scammer présumé, victime, témoin, suspect, personne liée +ou non catégorisée. La clé étrangère avec suppression en cascade évite les +rôles orphelins. Le code stable est persisté ; le libellé et la couleur restent +des choix de présentation. + +Une personne sans ligne dans cette table est toujours interprétée comme non +catégorisée, ce qui garantit la compatibilité avec les enquêtes antérieures. + --- # 6. Tables de liaison diff --git a/include/core/person_entity_service.h b/include/core/person_entity_service.h index cf7ded0..b07e9d8 100644 --- a/include/core/person_entity_service.h +++ b/include/core/person_entity_service.h @@ -5,6 +5,7 @@ #ifndef LABFY_INVESTIGATION_PERSON_ENTITY_SERVICE_H #define LABFY_INVESTIGATION_PERSON_ENTITY_SERVICE_H #include "database/database.h" +#include "models/entity_record.h" #include G_BEGIN_DECLS /** @brief Données factuelles utilisées pour créer une personne. */ @@ -28,5 +29,8 @@ typedef struct */ gboolean person_entity_service_create(Database *database, const PersonEntityInput *input, char **out_identifier, GError **error); +/** @brief Enregistre le rôle d'une personne existante. */ +gboolean person_entity_service_update_role(Database *database, + const char *entity_identifier, PersonRole role, GError **error); G_END_DECLS #endif diff --git a/include/database/schema.h b/include/database/schema.h index 39d45d4..ee6b0bc 100644 --- a/include/database/schema.h +++ b/include/database/schema.h @@ -81,6 +81,9 @@ bool schema_install_v4( Database *database ); +/** @brief Installe la migration des rôles de personnes du schéma V5. */ +bool schema_install_v5(Database *database); + /** * @brief Garantit la présence des extensions du schéma courant V2. * diff --git a/include/models/entity_record.h b/include/models/entity_record.h index d17cd13..f8546e3 100644 --- a/include/models/entity_record.h +++ b/include/models/entity_record.h @@ -29,6 +29,17 @@ typedef enum ENTITY_STATUS_DELETED } EntityStatus; +/** @brief Rôle d'une personne dans l'enquête. */ +typedef enum +{ + PERSON_ROLE_UNCATEGORIZED, + PERSON_ROLE_ALLEGED_SCAMMER, + PERSON_ROLE_VICTIM, + PERSON_ROLE_WITNESS, + PERSON_ROLE_SUSPECT, + PERSON_ROLE_RELATED_PERSON +} PersonRole; + /** * @brief Codes d'erreur produits par EntityRecord. */ @@ -170,6 +181,18 @@ EntityStatus entity_record_get_status( const EntityRecord *entity_record ); +/** @brief Affecte un rôle d'enquête à une entité de type personne. */ +gboolean entity_record_set_person_role(EntityRecord *entity_record, + PersonRole role); +/** @brief Retourne le rôle d'enquête, non catégorisé par défaut. */ +PersonRole entity_record_get_person_role(const EntityRecord *entity_record); +/** @brief Convertit un rôle en code stable pour SQLite. */ +const char *person_role_to_code(PersonRole role); +/** @brief Convertit un code SQLite en rôle. */ +PersonRole person_role_from_code(const char *code); +/** @brief Retourne le libellé français d'un rôle. */ +const char *person_role_get_label(PersonRole role); + G_END_DECLS #endif diff --git a/include/views/main_window.h b/include/views/main_window.h index a30abf8..e121a18 100644 --- a/include/views/main_window.h +++ b/include/views/main_window.h @@ -143,6 +143,10 @@ typedef void (*MainWindowEditRelationCallback)( gpointer user_data ); +/** @brief Callback appelé pour catégoriser une personne. */ +typedef void (*MainWindowPersonRoleCallback)(const char *entity_identifier, + PersonRole role, gpointer user_data); + /** * @brief Callback appelé lors du déclenchement d'une action OSINT. * @@ -240,6 +244,10 @@ void main_window_set_edit_relation_callback( gpointer user_data ); +/** @brief Définit le callback de catégorisation d'une personne. */ +void main_window_set_person_role_callback(MainWindow *main_window, + MainWindowPersonRoleCallback callback, gpointer user_data); + /** * @brief Définit le callback de déclenchement des actions OSINT. * @@ -437,6 +445,9 @@ gboolean main_window_select_graph_relation( MainWindow *main_window, const char *relation_identifier ); +/** @brief Sélectionne une entité dans le graphe affiché. */ +gboolean main_window_select_graph_entity(MainWindow *main_window, + const char *entity_identifier); /** * @brief Affiche une erreur de chargement du graphe. diff --git a/include/widgets/entity_details_panel.h b/include/widgets/entity_details_panel.h index c8319c2..19f2cc3 100644 --- a/include/widgets/entity_details_panel.h +++ b/include/widgets/entity_details_panel.h @@ -7,6 +7,7 @@ #define LABFY_INVESTIGATION_ENTITY_DETAILS_PANEL_H #include +#include "models/entity_record.h" G_BEGIN_DECLS @@ -45,6 +46,13 @@ typedef void (*EntityDetailsPanelAddRelationCallback)( gpointer user_data ); +/** @brief Callback appelé lors du changement de catégorie d'une personne. */ +typedef void (*EntityDetailsPanelPersonRoleCallback)( + const char *entity_identifier, + PersonRole role, + gpointer user_data +); + /** * @brief Crée un volet de détails fermé. * @@ -120,6 +128,13 @@ void entity_details_panel_set_add_relation_callback( gpointer user_data ); +/** @brief Définit le callback de changement de catégorie d'une personne. */ +void entity_details_panel_set_person_role_callback( + EntityDetailsPanel *details_panel, + EntityDetailsPanelPersonRoleCallback callback, + gpointer user_data +); + /** * @brief Indique si le volet est actuellement ouvert. * diff --git a/include/widgets/workspace.h b/include/widgets/workspace.h index 53df859..5ac2962 100644 --- a/include/widgets/workspace.h +++ b/include/widgets/workspace.h @@ -8,6 +8,7 @@ #include "core/investigation_node.h" #include "models/evidence_record.h" +#include "models/entity_record.h" #include "models/osint_action_catalog.h" #include @@ -84,6 +85,10 @@ typedef void (*WorkspaceEditRelationCallback)( gpointer user_data ); +/** @brief Callback appelé pour catégoriser une personne. */ +typedef void (*WorkspacePersonRoleCallback)(const char *entity_identifier, + PersonRole role, gpointer user_data); + /** * @brief Callback appelé lors du déclenchement d'une action OSINT. * @@ -301,6 +306,10 @@ void workspace_set_edit_relation_callback( gpointer user_data ); +/** @brief Définit le callback de catégorisation d'une personne. */ +void workspace_set_person_role_callback(Workspace *workspace, + WorkspacePersonRoleCallback callback, gpointer user_data); + /** * @brief Affiche l'état de chargement du graphe. * diff --git a/src/core/application.c b/src/core/application.c index 4a67291..5de727e 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -126,6 +126,7 @@ struct Application char *selected_evidence_identifier; char *pending_relation_selection_identifier; + char *pending_entity_selection_identifier; }; /** @@ -747,6 +748,13 @@ static void application_on_graph_loaded( g_clear_pointer(&application->pending_relation_selection_identifier, g_free); } + if (application->pending_entity_selection_identifier != NULL) + { + main_window_select_graph_entity(application->main_window, + application->pending_entity_selection_identifier); + g_clear_pointer(&application->pending_entity_selection_identifier, + g_free); + } status_message = g_strdup_printf( @@ -4935,6 +4943,35 @@ typedef struct char *relation_identifier; } ApplicationEditRelationContext; +/** @brief Enregistre la catégorie choisie depuis la fiche personne. */ +static void application_on_person_role_changed(const char *entity_identifier, + PersonRole role, gpointer user_data) +{ + Application *application = user_data; + Database *database = NULL; + const InvestigationProject *project = NULL; + GError *error = NULL; + if (application == NULL || application->session == NULL) return; + database = investigation_session_get_database(application->session); + project = investigation_session_get_project(application->session); + if (!person_entity_service_update_role(database, entity_identifier, + role, &error)) + { + application_present_error(application, "Catégorie non enregistrée", + error != NULL ? error->message : + "Impossible de catégoriser cette personne."); + g_clear_error(&error); + return; + } + g_free(application->pending_entity_selection_identifier); + application->pending_entity_selection_identifier = + g_strdup(entity_identifier); + main_window_set_status(application->main_window, + "Catégorie enregistrée. Actualisation du graphe…"); + application_start_graph_loading(application, + investigation_project_get_database_path(project)); +} + /** @brief Libère le contexte d'édition d'une relation. */ static void application_edit_relation_context_free( ApplicationEditRelationContext *context) @@ -6476,6 +6513,8 @@ static void application_on_activate( main_window_set_edit_relation_callback(application->main_window, application_on_edit_relation_requested, application); + main_window_set_person_role_callback(application->main_window, + application_on_person_role_changed, application); main_window_set_osint_action_callback( application->main_window, @@ -6752,6 +6791,7 @@ void application_free( ); g_clear_pointer(&application->pending_relation_selection_identifier, g_free); + g_clear_pointer(&application->pending_entity_selection_identifier, g_free); g_free( application diff --git a/src/core/person_entity_service.c b/src/core/person_entity_service.c index e17c75a..3f7687a 100644 --- a/src/core/person_entity_service.c +++ b/src/core/person_entity_service.c @@ -6,6 +6,7 @@ #include "dao/entity_dao.h" #include "dao/evidence_entity_dao.h" #include "database/transaction.h" +#include "database/statement.h" #include "models/entity_record.h" /** @brief Indique si le niveau d'identification est reconnu. */ @@ -16,6 +17,58 @@ static gboolean person_entity_service_status_valid(const char *status) g_strcmp0(status, "confirmed") == 0; } +gboolean person_entity_service_update_role(Database *database, + const char *entity_identifier, PersonRole role, GError **error) +{ + static const char validate_sql[] = + "SELECT 1 FROM entites e JOIN types_entite t ON t.id=e.type_id " + "WHERE e.id=? AND t.code='person';"; + static const char upsert_sql[] = + "INSERT INTO person_roles(entity_id, role, updated_at) VALUES(?,?,?) " + "ON CONFLICT(entity_id) DO UPDATE SET role=excluded.role, " + "updated_at=excluded.updated_at;"; + DatabaseStatement *statement = NULL; + GDateTime *now = NULL; + char *timestamp = NULL; + const char *role_code = person_role_to_code(role); + gboolean active = FALSE, success = FALSE; + g_return_val_if_fail(error == NULL || *error == NULL, FALSE); + if (database == NULL || entity_identifier == NULL || + !g_uuid_string_is_valid(entity_identifier) || role_code == NULL) + goto invalid; + statement = database_statement_prepare(database, validate_sql); + if (statement == NULL || !database_statement_bind_text(statement, 1, + entity_identifier) || database_statement_step(statement) != + DATABASE_STATEMENT_STEP_ROW) + goto invalid; + database_statement_finalize(statement); statement = NULL; + now = g_date_time_new_now_utc(); + timestamp = now != NULL ? g_date_time_format(now, + "%Y-%m-%dT%H:%M:%SZ") : NULL; + if (timestamp == NULL || !database_transaction_begin(database)) + goto cleanup; + active = TRUE; + statement = database_statement_prepare(database, upsert_sql); + if (statement == NULL || + !database_statement_bind_text(statement, 1, entity_identifier) || + !database_statement_bind_text(statement, 2, role_code) || + !database_statement_bind_text(statement, 3, timestamp) || + database_statement_step(statement) != DATABASE_STATEMENT_STEP_DONE) + goto cleanup; + database_statement_finalize(statement); statement = NULL; + if (!database_transaction_commit(database)) goto cleanup; + active = FALSE; success = TRUE; goto cleanup; +invalid: + g_set_error_literal(error, g_quark_from_static_string( + "person-entity-service-error"), 3, + "La personne ou la catégorie sélectionnée est invalide."); +cleanup: + database_statement_finalize(statement); + if (!success && active) database_transaction_rollback(database); + g_free(timestamp); g_clear_pointer(&now, g_date_time_unref); + return success; +} + gboolean person_entity_service_create(Database *database, const PersonEntityInput *input, char **out_identifier, GError **error) { diff --git a/src/dao/entity_dao.c b/src/dao/entity_dao.c index 8ab5a55..d6b95dd 100644 --- a/src/dao/entity_dao.c +++ b/src/dao/entity_dao.c @@ -68,10 +68,12 @@ static const char *const entity_dao_find_by_identifier_sql = " entites.confiance," " entites.created_at," " entites.updated_at," - " entites.status " + " entites.status," + " person_roles.role " "FROM entites " "LEFT JOIN types_entite " "ON types_entite.id = entites.type_id " + "LEFT JOIN person_roles ON person_roles.entity_id = entites.id " "WHERE entites.id = ?;"; /** @@ -87,10 +89,12 @@ static const char *const entity_dao_list_all_sql = " entites.confiance," " entites.created_at," " entites.updated_at," - " entites.status " + " entites.status," + " person_roles.role " "FROM entites " "LEFT JOIN types_entite " "ON types_entite.id = entites.type_id " + "LEFT JOIN person_roles ON person_roles.entity_id = entites.id " "ORDER BY " " entites.created_at ASC," " entites.id ASC;"; @@ -674,6 +678,9 @@ static EntityRecord *entity_dao_read_current_record( char *status_text = NULL; + char *person_role_text = + NULL; + int64_t confidence_value = -1; @@ -740,6 +747,11 @@ static EntityRecord *entity_dao_read_current_record( statement, 8, &status_text + ) || + !database_statement_column_text( + statement, + 9, + &person_role_text )) { entity_dao_set_error_literal( @@ -812,6 +824,11 @@ static EntityRecord *entity_dao_read_current_record( ); } } + else if (person_role_text != NULL) + { + entity_record_set_person_role(entity_record, + person_role_from_code(person_role_text)); + } cleanup: @@ -823,6 +840,8 @@ cleanup: status_text ); + g_free(person_role_text); + g_free( updated_at ); diff --git a/src/database/database.c b/src/database/database.c index a0c634e..fa9ff4b 100644 --- a/src/database/database.c +++ b/src/database/database.c @@ -16,12 +16,12 @@ /** * @brief Version actuelle du schéma SQLite. */ -#define DATABASE_SCHEMA_VERSION_CURRENT 4 +#define DATABASE_SCHEMA_VERSION_CURRENT 5 /** * @brief Version actuelle sous forme textuelle pour metadata. */ -#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "4" +#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "5" /** * @brief Nom de l'application enregistré dans les métadonnées. @@ -712,6 +712,24 @@ rollback: return false; } +/** @brief Applique atomiquement la migration du schéma V4 vers V5. */ +static bool database_migrate_v4_to_v5(Database *database) +{ + bool transaction_started = false; + if (database == NULL || !database_transaction_begin(database)) + return false; + transaction_started = true; + if (!schema_install_v5(database) || + !database_update_schema_version(database, "5") || + !database_transaction_commit(database)) + goto rollback; + return true; +rollback: + if (transaction_started && !database_transaction_rollback(database)) + g_warning("Impossible d’annuler la migration SQLite V4 vers V5."); + return false; +} + /** * @brief Garantit atomiquement la présence des extensions du schéma courant. */ @@ -910,6 +928,12 @@ bool database_migrate_to_latest( schema_version = 4; break; + case 4: + if (!database_migrate_v4_to_v5(database)) + return false; + schema_version = 5; + break; + default: database_set_error( database, @@ -1031,6 +1055,11 @@ bool database_initialize( goto rollback; } + if (!schema_install_v5(database)) + { + goto rollback; + } + if (!schema_ensure_current( database )) diff --git a/src/database/schema.c b/src/database/schema.c index a08768d..0c72013 100644 --- a/src/database/schema.c +++ b/src/database/schema.c @@ -215,6 +215,12 @@ bool schema_install_v4( ); } +bool schema_install_v5(Database *database) +{ + return schema_execute_file(database, "database/schema_v5.sql", + "la migration SQLite V5"); +} + bool schema_ensure_current( Database *database ) diff --git a/src/models/entity_record.c b/src/models/entity_record.c index 8d6f2ac..8e2fda1 100644 --- a/src/models/entity_record.c +++ b/src/models/entity_record.c @@ -24,6 +24,7 @@ struct EntityRecord char *updated_at; EntityStatus status; + PersonRole person_role; }; /** @@ -582,3 +583,62 @@ EntityStatus entity_record_get_status( ? entity_record->status : ENTITY_STATUS_UNKNOWN; } + +gboolean entity_record_set_person_role(EntityRecord *entity_record, + PersonRole role) +{ + if (entity_record == NULL || + g_strcmp0(entity_record->type_identifier, "person") != 0 || + person_role_to_code(role) == NULL) + { + return FALSE; + } + entity_record->person_role = role; + return TRUE; +} + +PersonRole entity_record_get_person_role(const EntityRecord *entity_record) +{ + return entity_record != NULL ? entity_record->person_role : + PERSON_ROLE_UNCATEGORIZED; +} + +const char *person_role_to_code(PersonRole role) +{ + switch (role) + { + case PERSON_ROLE_UNCATEGORIZED: return "uncategorized"; + case PERSON_ROLE_ALLEGED_SCAMMER: return "alleged_scammer"; + case PERSON_ROLE_VICTIM: return "victim"; + case PERSON_ROLE_WITNESS: return "witness"; + case PERSON_ROLE_SUSPECT: return "suspect"; + case PERSON_ROLE_RELATED_PERSON: return "related_person"; + default: return NULL; + } +} + +PersonRole person_role_from_code(const char *code) +{ + if (g_strcmp0(code, "alleged_scammer") == 0) + return PERSON_ROLE_ALLEGED_SCAMMER; + if (g_strcmp0(code, "victim") == 0) return PERSON_ROLE_VICTIM; + if (g_strcmp0(code, "witness") == 0) return PERSON_ROLE_WITNESS; + if (g_strcmp0(code, "suspect") == 0) return PERSON_ROLE_SUSPECT; + if (g_strcmp0(code, "related_person") == 0) + return PERSON_ROLE_RELATED_PERSON; + return PERSON_ROLE_UNCATEGORIZED; +} + +const char *person_role_get_label(PersonRole role) +{ + switch (role) + { + case PERSON_ROLE_ALLEGED_SCAMMER: return "Scammer présumé"; + case PERSON_ROLE_VICTIM: return "Victime"; + case PERSON_ROLE_WITNESS: return "Témoin"; + case PERSON_ROLE_SUSPECT: return "Suspect"; + case PERSON_ROLE_RELATED_PERSON: return "Personne liée"; + case PERSON_ROLE_UNCATEGORIZED: + default: return "Non catégorisé"; + } +} diff --git a/src/views/main_window.c b/src/views/main_window.c index e396a77..c6e1eab 100644 --- a/src/views/main_window.c +++ b/src/views/main_window.c @@ -131,6 +131,8 @@ struct MainWindow MainWindowEditRelationCallback edit_relation_callback; gpointer edit_relation_user_data; + MainWindowPersonRoleCallback person_role_callback; + gpointer person_role_user_data; MainWindowOsintActionCallback osint_action_callback; @@ -526,6 +528,16 @@ static void main_window_on_edit_relation_requested( main_window->edit_relation_user_data); } +/** @brief Relaie la catégorisation d'une personne. */ +static void main_window_on_person_role_changed(const char *entity_identifier, + PersonRole role, gpointer user_data) +{ + MainWindow *main_window = user_data; + if (main_window != NULL && main_window->person_role_callback != NULL) + main_window->person_role_callback(entity_identifier, role, + main_window->person_role_user_data); +} + /** * @brief Relaie la demande de vérification provenant du Workspace. */ @@ -866,6 +878,8 @@ MainWindow *main_window_new( workspace_set_edit_relation_callback(main_window->workspace, main_window_on_edit_relation_requested, main_window); + workspace_set_person_role_callback(main_window->workspace, + main_window_on_person_role_changed, main_window); workspace_set_osint_action_callback( main_window->workspace, @@ -1280,6 +1294,14 @@ gboolean main_window_select_graph_relation(MainWindow *main_window, relation_identifier); } +gboolean main_window_select_graph_entity(MainWindow *main_window, + const char *entity_identifier) +{ + if (main_window == NULL) return FALSE; + return workspace_select_graph_entity(main_window->workspace, + entity_identifier); +} + void main_window_set_graph_error( MainWindow *main_window, const char *message @@ -1650,6 +1672,14 @@ void main_window_set_edit_relation_callback(MainWindow *main_window, main_window->edit_relation_user_data = user_data; } +void main_window_set_person_role_callback(MainWindow *main_window, + MainWindowPersonRoleCallback callback, gpointer user_data) +{ + if (main_window == NULL) return; + main_window->person_role_callback = callback; + main_window->person_role_user_data = user_data; +} + void main_window_set_quit_callback( MainWindow *main_window, MainWindowQuitCallback callback, @@ -1803,6 +1833,7 @@ void main_window_free( workspace_set_edit_relation_callback(main_window->workspace, NULL, NULL); + workspace_set_person_role_callback(main_window->workspace, NULL, NULL); main_window->graph_node_moved_callback = NULL; @@ -1824,6 +1855,8 @@ void main_window_free( main_window->edit_relation_callback = NULL; main_window->edit_relation_user_data = NULL; + main_window->person_role_callback = NULL; + main_window->person_role_user_data = NULL; main_window->show_graph_callback = NULL; diff --git a/src/widgets/entity_details_panel.c b/src/widgets/entity_details_panel.c index b18b972..aa9fc8f 100644 --- a/src/widgets/entity_details_panel.c +++ b/src/widgets/entity_details_panel.c @@ -29,6 +29,8 @@ struct EntityDetailsPanel GtkWidget *entity_created_at_label; GtkWidget *entity_updated_at_label; GtkWidget *entity_identifier_label; + GtkWidget *person_role_box; + GtkDropDown *person_role_dropdown; char *selected_entity_identifier; @@ -40,8 +42,30 @@ struct EntityDetailsPanel gpointer add_relation_user_data; + + EntityDetailsPanelPersonRoleCallback person_role_callback; + gpointer person_role_user_data; + gboolean updating_person_role; }; +/** @brief Relaie une catégorie choisie explicitement par l'utilisateur. */ +static void entity_details_panel_on_person_role_changed(GObject *object, + GParamSpec *parameter, gpointer user_data) +{ + EntityDetailsPanel *details_panel = user_data; + guint selected = GTK_INVALID_LIST_POSITION; + (void) object; (void) parameter; + if (details_panel == NULL || details_panel->updating_person_role || + details_panel->person_role_callback == NULL || + details_panel->selected_entity_identifier == NULL) + return; + selected = gtk_drop_down_get_selected(details_panel->person_role_dropdown); + if (selected > PERSON_ROLE_RELATED_PERSON) return; + details_panel->person_role_callback( + details_panel->selected_entity_identifier, (PersonRole) selected, + details_panel->person_role_user_data); +} + /** * @brief Définit une valeur avec un texte de remplacement. */ @@ -314,6 +338,8 @@ EntityDetailsPanel *entity_details_panel_new(void) GtkWidget *details_grid = NULL; + GtkStringList *person_role_labels = NULL; + details_panel = g_try_new0( EntityDetailsPanel, @@ -695,6 +721,23 @@ EntityDetailsPanel *entity_details_panel_new(void) "Identifiant" ); + details_panel->person_role_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + person_role_labels = gtk_string_list_new(NULL); + for (guint role = PERSON_ROLE_UNCATEGORIZED; + role <= PERSON_ROLE_RELATED_PERSON; role++) + gtk_string_list_append(person_role_labels, + person_role_get_label((PersonRole) role)); + details_panel->person_role_dropdown = GTK_DROP_DOWN( + gtk_drop_down_new(G_LIST_MODEL(person_role_labels), NULL)); + person_role_labels = NULL; + gtk_box_append(GTK_BOX(details_panel->person_role_box), + gtk_label_new("Catégorie dans l’enquête")); + gtk_box_append(GTK_BOX(details_panel->person_role_box), + GTK_WIDGET(details_panel->person_role_dropdown)); + gtk_box_append(GTK_BOX(details_box), details_panel->person_role_box); + g_signal_connect(details_panel->person_role_dropdown, "notify::selected", + G_CALLBACK(entity_details_panel_on_person_role_changed), details_panel); + if (details_panel->entity_value_label == NULL || details_panel->entity_type_label == NULL || details_panel->entity_description_label == NULL || @@ -812,6 +855,14 @@ void entity_details_panel_set_entity( ) ); + gboolean is_person = g_strcmp0(entity_record_get_type_identifier( + entity_record), "person") == 0; + gtk_widget_set_visible(details_panel->person_role_box, is_person); + details_panel->updating_person_role = TRUE; + gtk_drop_down_set_selected(details_panel->person_role_dropdown, + (guint) entity_record_get_person_role(entity_record)); + details_panel->updating_person_role = FALSE; + entity_details_panel_update_add_relation_button( details_panel ); @@ -1013,6 +1064,8 @@ void entity_details_panel_clear( /* Le revealer fermé ne doit pas bloquer le canvas sous l'overlay. */ gtk_widget_set_can_target(details_panel->root_revealer, FALSE); } + if (details_panel->person_role_box != NULL) + gtk_widget_set_visible(details_panel->person_role_box, FALSE); } void entity_details_panel_set_close_callback( @@ -1055,6 +1108,15 @@ void entity_details_panel_set_add_relation_callback( ); } +void entity_details_panel_set_person_role_callback( + EntityDetailsPanel *details_panel, + EntityDetailsPanelPersonRoleCallback callback, gpointer user_data) +{ + if (details_panel == NULL) return; + details_panel->person_role_callback = callback; + details_panel->person_role_user_data = user_data; +} + gboolean entity_details_panel_is_open( const EntityDetailsPanel *details_panel ) diff --git a/src/widgets/investigation_graph_view.c b/src/widgets/investigation_graph_view.c index 55fa2bb..ed4d9aa 100644 --- a/src/widgets/investigation_graph_view.c +++ b/src/widgets/investigation_graph_view.c @@ -3304,6 +3304,8 @@ static void investigation_graph_view_draw_entity( SocialPlatform social_platform = SOCIAL_PLATFORM_NONE; + PersonRole person_role = PERSON_ROLE_UNCATEGORIZED; + double text_x = x + INVESTIGATION_GRAPH_VIEW_NODE_PADDING; @@ -3325,7 +3327,28 @@ static void investigation_graph_view_draw_entity( INVESTIGATION_GRAPH_VIEW_NODE_RADIUS ); - if (selected) + if (g_strcmp0(entity_record_get_type_identifier(entity_record), + "person") == 0) + { + person_role = entity_record_get_person_role(entity_record); + switch (person_role) + { + case PERSON_ROLE_ALLEGED_SCAMMER: + cairo_set_source_rgb(cairo_context, 0.55, 0.12, 0.14); break; + case PERSON_ROLE_VICTIM: + cairo_set_source_rgb(cairo_context, 0.12, 0.42, 0.22); break; + case PERSON_ROLE_WITNESS: + cairo_set_source_rgb(cairo_context, 0.12, 0.30, 0.56); break; + case PERSON_ROLE_SUSPECT: + cairo_set_source_rgb(cairo_context, 0.62, 0.32, 0.08); break; + case PERSON_ROLE_RELATED_PERSON: + cairo_set_source_rgb(cairo_context, 0.38, 0.20, 0.52); break; + case PERSON_ROLE_UNCATEGORIZED: + default: + cairo_set_source_rgb(cairo_context, 0.25, 0.27, 0.31); break; + } + } + else if (selected) { cairo_set_source_rgb( cairo_context, @@ -3393,6 +3416,9 @@ static void investigation_graph_view_draw_entity( social_platform = social_platform_from_entity_type(type_identifier); + if (g_strcmp0(type_identifier, "person") == 0) + type_label = person_role_get_label(person_role); + if (social_platform != SOCIAL_PLATFORM_NONE) { investigation_graph_view_draw_social_icon( diff --git a/src/widgets/workspace.c b/src/widgets/workspace.c index e97f7c7..178fbb8 100644 --- a/src/widgets/workspace.c +++ b/src/widgets/workspace.c @@ -124,6 +124,8 @@ struct Workspace WorkspaceEditRelationCallback edit_relation_callback; gpointer edit_relation_user_data; + WorkspacePersonRoleCallback person_role_callback; + gpointer person_role_user_data; WorkspaceOsintActionCallback osint_action_callback; @@ -782,6 +784,16 @@ static void workspace_on_add_relation_requested( ); } +/** @brief Relaie le changement de catégorie d'une personne. */ +static void workspace_on_person_role_changed(const char *entity_identifier, + PersonRole role, gpointer user_data) +{ + Workspace *workspace = user_data; + if (workspace != NULL && workspace->person_role_callback != NULL) + workspace->person_role_callback(entity_identifier, role, + workspace->person_role_user_data); +} + /** * @brief Désélectionne le nœud lorsque le volet est fermé manuellement. */ @@ -1654,6 +1666,10 @@ Workspace *workspace_new(void) workspace ); + entity_details_panel_set_person_role_callback( + workspace->entity_details_panel, workspace_on_person_role_changed, + workspace); + gtk_overlay_set_child( GTK_OVERLAY( workspace->graph_view_page @@ -2993,6 +3009,14 @@ void workspace_set_edit_relation_callback(Workspace *workspace, workspace->edit_relation_user_data = user_data; } +void workspace_set_person_role_callback(Workspace *workspace, + WorkspacePersonRoleCallback callback, gpointer user_data) +{ + if (workspace == NULL) return; + workspace->person_role_callback = callback; + workspace->person_role_user_data = user_data; +} + void workspace_reset_graph_layout( Workspace *workspace ) @@ -3059,6 +3083,8 @@ void workspace_free(Workspace *workspace) NULL, NULL ); + entity_details_panel_set_person_role_callback( + workspace->entity_details_panel, NULL, NULL); entity_details_panel_clear( workspace->entity_details_panel diff --git a/tests/test_database.c b/tests/test_database.c index c96802f..ccc333c 100644 --- a/tests/test_database.c +++ b/tests/test_database.c @@ -525,11 +525,12 @@ static void test_database_initialize_valid_database(void) "FROM investigation;" ); - assert(strcmp(schema_version, "4") == 0); + assert(strcmp(schema_version, "5") == 0); test_database_assert_table_exists(database, "osint_executions"); test_database_assert_table_exists(database, "osint_execution_entities"); test_database_assert_table_exists(database, "osint_execution_relations"); test_database_assert_table_exists(database, "comptes_sociaux"); + test_database_assert_table_exists(database, "person_roles"); assert(strcmp(application_name, "Labfy Investigation") == 0); assert(created_at[0] != '\0'); @@ -988,7 +989,7 @@ static void test_database_migrate_v1_to_v2(void) assert( strcmp( schema_version, - "4" + "5" ) == 0 ); diff --git a/tests/test_person_entity_service.c b/tests/test_person_entity_service.c index fd6ebfc..52aec3d 100644 --- a/tests/test_person_entity_service.c +++ b/tests/test_person_entity_service.c @@ -37,6 +37,13 @@ static void test_person_entity_create(void) assert(record != NULL && error == NULL); assert(strcmp(entity_record_get_type_identifier(record), "person") == 0); assert(entity_record_get_confidence(record) == 30); + assert(entity_record_get_person_role(record) == PERSON_ROLE_UNCATEGORIZED); + entity_record_free(record); record = NULL; + assert(person_entity_service_update_role(database, identifier, + PERSON_ROLE_VICTIM, &error)); + record = entity_dao_find_by_identifier(dao, identifier, &error); + assert(record != NULL && error == NULL); + assert(entity_record_get_person_role(record) == PERSON_ROLE_VICTIM); entity_record_free(record); entity_dao_free(dao); database_close(database); assert(g_remove(path) == 0); assert(g_rmdir(directory) == 0); g_free(identifier); g_free(path); g_free(directory);