diff --git a/README.md b/README.md index d65d5aa..99a9dde 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ Le socle actuel comprend notamment : - arborescence des fichiers ; - fenêtre principale GTK4 ; - barre latérale et espace de travail ; +- navigation et recherche locale des entités dans la barre latérale ; - affichage graphique des erreurs ; - tâches asynchrones annulables ; - gestionnaire de tâches ; diff --git a/include/widgets/investigation_graph_view.h b/include/widgets/investigation_graph_view.h index 0028dc4..3e9a742 100644 --- a/include/widgets/investigation_graph_view.h +++ b/include/widgets/investigation_graph_view.h @@ -183,6 +183,21 @@ const EntityRecord *investigation_graph_view_get_selected_entity( const InvestigationGraphView *graph_view ); +/** + * @brief Sélectionne une entité du graphe par son UUID. + * + * La vue reste inchangée si l'identifiant est absent du graphe. + * + * @param graph_view Vue graphique à modifier. + * @param entity_identifier UUID de l'entité. + * + * @return TRUE lorsque l'entité est trouvée et sélectionnée. + */ +gboolean investigation_graph_view_select_entity( + InvestigationGraphView *graph_view, + const char *entity_identifier +); + /** * @brief Supprime la sélection courante. * diff --git a/include/widgets/sidebar.h b/include/widgets/sidebar.h index 362b16b..4a6d062 100644 --- a/include/widgets/sidebar.h +++ b/include/widgets/sidebar.h @@ -9,6 +9,7 @@ #include "core/investigation_tree_model.h" #include "widgets/investigation_tree_view.h" #include "widgets/evidence_category_model.h" +#include "models/investigation_graph_model.h" #include @@ -34,6 +35,20 @@ typedef void (*SidebarEvidenceSelectionCallback)( gpointer user_data ); +/** + * @brief Callback appelé lorsqu'une entité est sélectionnée. + * + * L'identifiant est emprunté et valide uniquement pendant l'appel. + * Une sélection vide transmet NULL. + * + * @param entity_identifier UUID de l'entité, ou NULL. + * @param user_data Données privées fournies par l'appelant. + */ +typedef void (*SidebarEntitySelectionCallback)( + const char *entity_identifier, + gpointer user_data +); + /** * @brief Crée un nouveau panneau de navigation latéral. * @@ -113,6 +128,32 @@ void sidebar_set_evidence_selection_callback( gpointer user_data ); +/** + * @brief Installe les entités du graphe dans la barre latérale. + * + * La Sidebar emprunte graph_model. Passer NULL vide la liste. + * + * @param sidebar Barre latérale à actualiser. + * @param graph_model Graphe emprunté, ou NULL. + */ +void sidebar_set_entity_model( + Sidebar *sidebar, + const InvestigationGraphModel *graph_model +); + +/** + * @brief Définit le callback de sélection d'une entité. + * + * @param sidebar Barre latérale à configurer. + * @param callback Callback facultatif. + * @param user_data Données privées transmises au callback. + */ +void sidebar_set_entity_selection_callback( + Sidebar *sidebar, + SidebarEntitySelectionCallback callback, + gpointer user_data +); + /** * @brief Libère la structure d'encapsulation du panneau latéral. * diff --git a/include/widgets/workspace.h b/include/widgets/workspace.h index d3d44cb..11f08b2 100644 --- a/include/widgets/workspace.h +++ b/include/widgets/workspace.h @@ -127,6 +127,21 @@ void workspace_set_selected_evidence( const EvidenceRecord *evidence_record ); +/** + * @brief Sélectionne une entité dans le graphe affiché. + * + * La sélection actualise également le volet de détails et le contexte OSINT. + * + * @param workspace Zone de travail. + * @param entity_identifier UUID de l'entité à sélectionner. + * + * @return TRUE lorsque l'entité est trouvée et sélectionnée. + */ +gboolean workspace_select_graph_entity( + Workspace *workspace, + const char *entity_identifier +); + /** * @brief Définit le callback de vérification de la preuve affichée. * diff --git a/src/views/main_window.c b/src/views/main_window.c index 3e1a8c1..d11369f 100644 --- a/src/views/main_window.c +++ b/src/views/main_window.c @@ -125,6 +125,29 @@ struct MainWindow }; +/** + * @brief Ouvre dans le workspace l'entité choisie dans la sidebar. + */ +static void main_window_on_sidebar_entity_selected( + const char *entity_identifier, + gpointer user_data +) +{ + MainWindow *main_window = user_data; + + if (main_window == NULL || + entity_identifier == NULL || + entity_identifier[0] == '\0') + { + return; + } + + workspace_select_graph_entity( + main_window->workspace, + entity_identifier + ); +} + /** * @brief Transmet la demande de création d'une enquête au contrôleur. * @@ -663,6 +686,12 @@ MainWindow *main_window_new( return NULL; } + sidebar_set_entity_selection_callback( + main_window->sidebar, + main_window_on_sidebar_entity_selected, + main_window + ); + workspace_set_verify_evidence_callback( main_window->workspace, main_window_on_verify_evidence_requested, @@ -1040,6 +1069,11 @@ void main_window_set_graph_loading( FALSE ); + sidebar_set_entity_model( + main_window->sidebar, + NULL + ); + workspace_set_graph_loading( main_window->workspace ); @@ -1062,6 +1096,11 @@ void main_window_set_graph( graph_layout ); + sidebar_set_entity_model( + main_window->sidebar, + graph_model + ); + gtk_widget_set_sensitive( main_window->show_graph_button, graph_model != NULL @@ -1083,6 +1122,11 @@ void main_window_set_graph_error( FALSE ); + sidebar_set_entity_model( + main_window->sidebar, + NULL + ); + workspace_set_graph_error( main_window->workspace, message @@ -1103,6 +1147,11 @@ void main_window_clear_graph( FALSE ); + sidebar_set_entity_model( + main_window->sidebar, + NULL + ); + workspace_clear_graph( main_window->workspace ); @@ -1436,6 +1485,17 @@ void main_window_free( main_window->sidebar, NULL ); + + sidebar_set_entity_selection_callback( + main_window->sidebar, + NULL, + NULL + ); + + sidebar_set_entity_model( + main_window->sidebar, + NULL + ); } /* @@ -1526,4 +1586,3 @@ void main_window_free( main_window ); } - diff --git a/src/widgets/investigation_graph_view.c b/src/widgets/investigation_graph_view.c index 297310b..3157c6c 100644 --- a/src/widgets/investigation_graph_view.c +++ b/src/widgets/investigation_graph_view.c @@ -4257,6 +4257,40 @@ const EntityRecord *investigation_graph_view_get_selected_entity( return graph_view->selected_node->entity_record; } +gboolean investigation_graph_view_select_entity( + InvestigationGraphView *graph_view, + const char *entity_identifier +) +{ + InvestigationGraphNodeLayout *node_layout = NULL; + + if (graph_view == NULL || + graph_view->node_layouts_by_identifier == NULL || + entity_identifier == NULL || + !g_uuid_string_is_valid(entity_identifier)) + { + return FALSE; + } + + node_layout = g_hash_table_lookup( + graph_view->node_layouts_by_identifier, + entity_identifier + ); + + if (node_layout == NULL || + node_layout->kind != INVESTIGATION_GRAPH_NODE_KIND_ENTITY) + { + return FALSE; + } + + investigation_graph_view_set_selected_node( + graph_view, + node_layout + ); + + return TRUE; +} + void investigation_graph_view_clear_selection( InvestigationGraphView *graph_view ) diff --git a/src/widgets/sidebar.c b/src/widgets/sidebar.c index b885575..4e7aaf3 100644 --- a/src/widgets/sidebar.c +++ b/src/widgets/sidebar.c @@ -9,6 +9,7 @@ #include "widgets/evidence_tree_view.h" #include +#include /** * @brief Largeur initiale du panneau latéral. @@ -27,6 +28,12 @@ #define SIDEBAR_PAGE_EVIDENCE \ "evidence" +/** + * @brief Identifiant de la page contenant les entités. + */ +#define SIDEBAR_PAGE_ENTITY \ + "entity" + /** * @brief État vide de l'onglet Preuves. */ @@ -61,6 +68,14 @@ struct Sidebar GtkWidget *evidence_empty_page; GtkWidget *evidence_empty_label; + GtkWidget *entity_page; + GtkWidget *entity_search_entry; + GtkWidget *entity_count_label; + GtkWidget *entity_scrolled_window; + GtkWidget *entity_list_box; + GtkWidget *entity_empty_label; + GtkWidget *entity_no_result_label; + InvestigationTreeView *tree_view; EvidenceTreeView *evidence_tree_view; @@ -68,8 +83,132 @@ struct Sidebar evidence_selection_callback; gpointer evidence_selection_user_data; + + SidebarEntitySelectionCallback + entity_selection_callback; + + gpointer entity_selection_user_data; }; +/** + * @brief Filtre une ligne d'entité avec le texte de recherche courant. + */ +static gboolean sidebar_filter_entity_row( + GtkListBoxRow *row, + gpointer user_data +) +{ + Sidebar *sidebar = user_data; + const EntityRecord *entity_record = NULL; + const char *search_text = NULL; + char *normalized_search = NULL; + char *normalized_value = NULL; + char *normalized_type = NULL; + gboolean visible = TRUE; + + if (sidebar == NULL || row == NULL) + { + return FALSE; + } + + search_text = gtk_editable_get_text( + GTK_EDITABLE(sidebar->entity_search_entry) + ); + + if (search_text == NULL || search_text[0] == '\0') + { + return TRUE; + } + + entity_record = g_object_get_data( + G_OBJECT(row), + "entity-record" + ); + + if (entity_record == NULL) + { + return FALSE; + } + + normalized_search = g_utf8_casefold(search_text, -1); + normalized_value = g_utf8_casefold( + entity_record_get_value(entity_record), + -1 + ); + normalized_type = g_utf8_casefold( + entity_record_get_type_identifier(entity_record), + -1 + ); + + visible = normalized_search != NULL && + ((normalized_value != NULL && + strstr(normalized_value, normalized_search) != NULL) || + (normalized_type != NULL && + strstr(normalized_type, normalized_search) != NULL)); + + g_free(normalized_type); + g_free(normalized_value); + g_free(normalized_search); + + return visible; +} + +/** + * @brief Réapplique le filtre local des entités. + */ +static void sidebar_on_entity_search_changed( + GtkSearchEntry *search_entry, + gpointer user_data +) +{ + Sidebar *sidebar = user_data; + + (void) search_entry; + + if (sidebar != NULL && sidebar->entity_list_box != NULL) + { + gtk_list_box_invalidate_filter( + GTK_LIST_BOX(sidebar->entity_list_box) + ); + } +} + +/** + * @brief Relaie la sélection provenant de la liste d'entités. + */ +static void sidebar_on_entity_row_selected( + GtkListBox *list_box, + GtkListBoxRow *row, + gpointer user_data +) +{ + Sidebar *sidebar = user_data; + const EntityRecord *entity_record = NULL; + const char *entity_identifier = NULL; + + (void) list_box; + + if (sidebar == NULL || + sidebar->entity_selection_callback == NULL) + { + return; + } + + if (row != NULL) + { + entity_record = g_object_get_data( + G_OBJECT(row), + "entity-record" + ); + entity_identifier = entity_record_get_identifier(entity_record); + } + + sidebar->entity_selection_callback( + entity_identifier, + sidebar->entity_selection_user_data + ); +} + /** * @brief Relaie la sélection provenant de EvidenceTreeView. */ @@ -585,6 +724,129 @@ Sidebar *sidebar_new(void) "Preuves" ); + /* + * Page Entités : recherche locale, compteur et liste sélectionnable. + */ + sidebar->entity_page = gtk_box_new( + GTK_ORIENTATION_VERTICAL, + 8 + ); + sidebar->entity_search_entry = gtk_search_entry_new(); + sidebar->entity_count_label = gtk_label_new("0 entité"); + sidebar->entity_scrolled_window = gtk_scrolled_window_new(); + sidebar->entity_list_box = gtk_list_box_new(); + sidebar->entity_empty_label = gtk_label_new( + "Aucune entité active dans cette enquête." + ); + sidebar->entity_no_result_label = gtk_label_new( + "Aucune entité ne correspond à la recherche." + ); + + if (sidebar->entity_page == NULL || + sidebar->entity_search_entry == NULL || + sidebar->entity_count_label == NULL || + sidebar->entity_scrolled_window == NULL || + sidebar->entity_list_box == NULL || + sidebar->entity_empty_label == NULL || + sidebar->entity_no_result_label == NULL) + { + sidebar_free(sidebar); + return NULL; + } + + gtk_widget_set_margin_start(sidebar->entity_search_entry, 8); + gtk_widget_set_margin_end(sidebar->entity_search_entry, 8); + gtk_search_entry_set_placeholder_text( + GTK_SEARCH_ENTRY(sidebar->entity_search_entry), + "Rechercher une entité" + ); + gtk_widget_set_halign( + sidebar->entity_count_label, + GTK_ALIGN_START + ); + gtk_widget_set_margin_start(sidebar->entity_count_label, 12); + gtk_widget_add_css_class( + sidebar->entity_count_label, + "dim-label" + ); + gtk_widget_set_hexpand(sidebar->entity_scrolled_window, TRUE); + gtk_widget_set_vexpand(sidebar->entity_scrolled_window, TRUE); + gtk_scrolled_window_set_policy( + GTK_SCROLLED_WINDOW(sidebar->entity_scrolled_window), + GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC + ); + gtk_scrolled_window_set_has_frame( + GTK_SCROLLED_WINDOW(sidebar->entity_scrolled_window), + FALSE + ); + gtk_widget_set_hexpand(sidebar->entity_list_box, TRUE); + gtk_scrolled_window_set_child( + GTK_SCROLLED_WINDOW(sidebar->entity_scrolled_window), + sidebar->entity_list_box + ); + gtk_list_box_set_selection_mode( + GTK_LIST_BOX(sidebar->entity_list_box), + GTK_SELECTION_SINGLE + ); + gtk_list_box_set_filter_func( + GTK_LIST_BOX(sidebar->entity_list_box), + sidebar_filter_entity_row, + sidebar, + NULL + ); + gtk_label_set_wrap( + GTK_LABEL(sidebar->entity_no_result_label), + TRUE + ); + gtk_widget_set_margin_start(sidebar->entity_no_result_label, 20); + gtk_widget_set_margin_end(sidebar->entity_no_result_label, 20); + gtk_widget_set_margin_top(sidebar->entity_no_result_label, 20); + gtk_list_box_set_placeholder( + GTK_LIST_BOX(sidebar->entity_list_box), + sidebar->entity_no_result_label + ); + gtk_widget_set_halign(sidebar->entity_empty_label, GTK_ALIGN_CENTER); + gtk_widget_set_valign(sidebar->entity_empty_label, GTK_ALIGN_CENTER); + gtk_widget_set_vexpand(sidebar->entity_empty_label, TRUE); + gtk_label_set_wrap(GTK_LABEL(sidebar->entity_empty_label), TRUE); + + g_signal_connect( + sidebar->entity_search_entry, + "search-changed", + G_CALLBACK(sidebar_on_entity_search_changed), + sidebar + ); + g_signal_connect( + sidebar->entity_list_box, + "row-selected", + G_CALLBACK(sidebar_on_entity_row_selected), + sidebar + ); + + gtk_box_append( + GTK_BOX(sidebar->entity_page), + sidebar->entity_search_entry + ); + gtk_box_append( + GTK_BOX(sidebar->entity_page), + sidebar->entity_count_label + ); + gtk_box_append( + GTK_BOX(sidebar->entity_page), + sidebar->entity_scrolled_window + ); + gtk_box_append( + GTK_BOX(sidebar->entity_page), + sidebar->entity_empty_label + ); + gtk_stack_add_titled( + GTK_STACK(sidebar->stack), + sidebar->entity_page, + SIDEBAR_PAGE_ENTITY, + "Entités" + ); + /* * L'onglet Dossier reste sélectionné au démarrage. */ @@ -751,6 +1013,126 @@ void sidebar_set_evidence_selection_callback( user_data; } +void sidebar_set_entity_model( + Sidebar *sidebar, + const InvestigationGraphModel *graph_model +) +{ + GPtrArray *entity_records = NULL; + GtkWidget *child = NULL; + guint entity_index = 0; + char *count_text = NULL; + + if (sidebar == NULL || sidebar->entity_list_box == NULL) + { + return; + } + + while ((child = gtk_widget_get_first_child( + sidebar->entity_list_box + )) != NULL) + { + gtk_list_box_remove( + GTK_LIST_BOX(sidebar->entity_list_box), + child + ); + } + + if (graph_model != NULL) + { + entity_records = investigation_graph_model_list_entities( + graph_model, + NULL + ); + } + + for (entity_index = 0; + entity_records != NULL && entity_index < entity_records->len; + entity_index++) + { + const EntityRecord *entity_record = g_ptr_array_index( + entity_records, + entity_index + ); + GtkWidget *row = gtk_list_box_row_new(); + GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + GtkWidget *value_label = gtk_label_new( + entity_record_get_value(entity_record) + ); + GtkWidget *type_label = gtk_label_new( + entity_record_get_type_identifier(entity_record) + ); + + if (row == NULL || content == NULL || + value_label == NULL || type_label == NULL) + { + continue; + } + + gtk_widget_set_margin_start(content, 12); + gtk_widget_set_margin_end(content, 12); + gtk_widget_set_margin_top(content, 8); + gtk_widget_set_margin_bottom(content, 8); + gtk_label_set_xalign(GTK_LABEL(value_label), 0.0F); + gtk_label_set_ellipsize( + GTK_LABEL(value_label), + PANGO_ELLIPSIZE_END + ); + gtk_label_set_xalign(GTK_LABEL(type_label), 0.0F); + gtk_widget_add_css_class(type_label, "dim-label"); + gtk_box_append(GTK_BOX(content), value_label); + gtk_box_append(GTK_BOX(content), type_label); + gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), content); + g_object_set_data( + G_OBJECT(row), + "entity-record", + (gpointer) entity_record + ); + gtk_list_box_append( + GTK_LIST_BOX(sidebar->entity_list_box), + row + ); + } + + count_text = g_strdup_printf( + "%u %s", + entity_records != NULL ? entity_records->len : 0U, + entity_records != NULL && entity_records->len > 1U + ? "entités" + : "entité" + ); + gtk_label_set_text( + GTK_LABEL(sidebar->entity_count_label), + count_text != NULL ? count_text : "0 entité" + ); + gtk_widget_set_visible( + sidebar->entity_empty_label, + entity_records == NULL || entity_records->len == 0U + ); + gtk_widget_set_visible( + sidebar->entity_scrolled_window, + entity_records != NULL && entity_records->len > 0U + ); + + g_free(count_text); + g_clear_pointer(&entity_records, g_ptr_array_unref); +} + +void sidebar_set_entity_selection_callback( + Sidebar *sidebar, + SidebarEntitySelectionCallback callback, + gpointer user_data +) +{ + if (sidebar == NULL) + { + return; + } + + sidebar->entity_selection_callback = callback; + sidebar->entity_selection_user_data = user_data; +} + void sidebar_free(Sidebar *sidebar) { if (sidebar == NULL) @@ -773,6 +1155,9 @@ void sidebar_free(Sidebar *sidebar) ); } + sidebar->entity_selection_callback = NULL; + sidebar->entity_selection_user_data = NULL; + /* * Les widgets GTK sont intégrés dans l'arbre de widgets de la fenêtre. * GTK gère leur destruction lorsque la fenêtre est détruite. diff --git a/src/widgets/workspace.c b/src/widgets/workspace.c index fafdb61..8aa22ac 100644 --- a/src/widgets/workspace.c +++ b/src/widgets/workspace.c @@ -2045,6 +2045,22 @@ void workspace_set_selected_evidence( ); } +gboolean workspace_select_graph_entity( + Workspace *workspace, + const char *entity_identifier +) +{ + if (workspace == NULL || workspace->graph_view == NULL) + { + return FALSE; + } + + return investigation_graph_view_select_entity( + workspace->graph_view, + entity_identifier + ); +} + void workspace_set_graph_loading( Workspace *workspace )