diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aa88d8..6547d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ### Added +- Référentiel persistant et normalisé des types de relations, avec codes + système stables, types personnalisés, renommage et fusion transactionnelle. +- Sélecteur canonique dans les formulaires de création et de modification des + relations. - Glisser-déposer confirmé des extractions texte vers le graphe, avec persistance de leur association à une entité. - Ouverture de l’aperçu des pièces jointes et preuves depuis les fiches diff --git a/Makefile b/Makefile index 06144ea..21114b8 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,8 @@ TEST_CFLAGS = -std=c17 \ -Iinclude \ $(shell $(PKG_CONFIG) --cflags glib-2.0 gio-2.0) -TEST_LDFLAGS = $(shell $(PKG_CONFIG) --libs glib-2.0 gio-2.0) +TEST_LDFLAGS = src/core/relation_type_normalizer.c \ + $(shell $(PKG_CONFIG) --libs glib-2.0 gio-2.0) EVIDENCE_RECORD_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic EVIDENCE_TYPE_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic @@ -129,9 +130,29 @@ TEST_IBAN_ANALYZER := tests/test_iban_analyzer TEST_EXIFTOOL_METADATA := tests/test_exiftool_metadata TEST_PDF_PASSWORD_RECOVERY := tests/test_pdf_password_recovery TEST_EXTRACTION_DROP_SERVICE := tests/test_extraction_drop_service +TEST_RELATION_TYPE_NORMALIZER := tests/test_relation_type_normalizer +TEST_RELATION_TYPE_SERVICE := tests/test_relation_type_service all: $(TARGET) +$(TEST_RELATION_TYPE_NORMALIZER): \ + tests/test_relation_type_normalizer.c \ + src/core/relation_type_normalizer.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ \ + $(shell $(PKG_CONFIG) --libs glib-2.0) + +$(TEST_RELATION_TYPE_SERVICE): \ + tests/test_relation_type_service.c \ + src/core/relation_type_service.c \ + src/dao/relation_type_dao.c \ + src/models/relation_type.c \ + src/database/database.c \ + src/database/schema.c \ + src/database/statement.c \ + src/database/transaction.c \ + src/database/error.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) -lsqlite3 + $(TARGET): $(OBJ) $(CC) $(OBJ) -o $@ $(LDFLAGS) @@ -480,6 +501,8 @@ $(TEST_RELATION_RECORD): \ $(TEST_RELATION_DAO): \ tests/test_relation_dao.c \ src/dao/relation_dao.c \ + src/dao/relation_type_dao.c \ + src/models/relation_type.c \ src/dao/entity_dao.c \ src/models/relation_record.c \ src/models/entity_record.c \ @@ -494,6 +517,8 @@ $(TEST_RELATION_EVIDENCE_DAO): \ tests/test_relation_evidence_dao.c \ src/dao/relation_evidence_dao.c \ src/dao/relation_dao.c \ + src/dao/relation_type_dao.c \ + src/models/relation_type.c \ src/dao/evidence_dao.c \ src/dao/entity_dao.c \ src/models/relation_record.c \ @@ -510,6 +535,8 @@ $(TEST_RELATION_SERVICE): \ tests/test_relation_service.c \ src/core/relation_service.c \ src/dao/relation_dao.c \ + src/dao/relation_type_dao.c \ + src/models/relation_type.c \ src/dao/relation_evidence_dao.c \ src/dao/evidence_dao.c \ src/dao/entity_dao.c \ @@ -537,6 +564,8 @@ $(TEST_INVESTIGATION_GRAPH_LOADER): \ src/core/investigation_graph_loader.c \ src/dao/entity_dao.c \ src/dao/relation_dao.c \ + src/dao/relation_type_dao.c \ + src/models/relation_type.c \ src/models/investigation_graph_model.c \ src/models/entity_record.c \ src/models/relation_record.c \ @@ -596,6 +625,8 @@ $(TEST_OSINT_DNS_INTEGRATION): \ src/models/osint_execution_record.c \ src/dao/entity_dao.c \ src/dao/relation_dao.c \ + src/dao/relation_type_dao.c \ + src/models/relation_type.c \ src/dao/osint_execution_dao.c \ src/database/database.c \ src/database/schema.c \ @@ -691,6 +722,8 @@ $(TEST_INVESTIGATION_GRAPH_LOAD_TASK): \ src/models/graph_node_position.c \ src/dao/graph_node_position_dao.c \ src/dao/relation_dao.c \ + src/dao/relation_type_dao.c \ + src/models/relation_type.c \ src/models/investigation_graph_model.c \ src/models/entity_record.c \ src/models/relation_record.c \ @@ -764,7 +797,9 @@ test: \ $(TEST_IBAN_ANALYZER) \ $(TEST_EXIFTOOL_METADATA) \ $(TEST_PDF_PASSWORD_RECOVERY) \ - $(TEST_EXTRACTION_DROP_SERVICE) + $(TEST_EXTRACTION_DROP_SERVICE) \ + $(TEST_RELATION_TYPE_NORMALIZER) \ + $(TEST_RELATION_TYPE_SERVICE) @echo "Exécution des tests..." @./$(TEST_NODE) @./$(TEST_TREE_MODEL) @@ -828,6 +863,8 @@ test: \ @$(TEST_EXIFTOOL_METADATA) @$(TEST_PDF_PASSWORD_RECOVERY) @$(TEST_EXTRACTION_DROP_SERVICE) + @$(TEST_RELATION_TYPE_NORMALIZER) + @$(TEST_RELATION_TYPE_SERVICE) @echo "Tous les tests sont valides." %.o: %.c @@ -894,7 +931,9 @@ clean: $(TEST_SOCIAL_PLATFORM) \ $(TEST_PERSON_ENTITY_SERVICE) \ $(TEST_EML_ANALYZER) \ - $(TEST_EXTRACTION_DROP_SERVICE) + $(TEST_EXTRACTION_DROP_SERVICE) \ + $(TEST_RELATION_TYPE_NORMALIZER) \ + $(TEST_RELATION_TYPE_SERVICE) -include $(DEP) diff --git a/database/schema_current.sql b/database/schema_current.sql index e4796cd..dd936fe 100644 --- a/database/schema_current.sql +++ b/database/schema_current.sql @@ -1,7 +1,7 @@ /****************************************************************************** * Labfy Investigation * - * Extensions idempotentes du schéma SQLite courant V8 + * Extensions idempotentes du schéma SQLite courant V9 ******************************************************************************/ /* @@ -102,6 +102,19 @@ CREATE TABLE IF NOT EXISTS graph_viewport updated_at TEXT NOT NULL CHECK (length(updated_at) = 20) ); +CREATE TABLE IF NOT EXISTS relation_types +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE, + label TEXT NOT NULL, + normalized_key TEXT NOT NULL UNIQUE, + description TEXT, + is_system INTEGER NOT NULL DEFAULT 0 CHECK (is_system IN (0, 1)), + CHECK (code IS NULL OR length(trim(code)) > 0), + CHECK (length(trim(label)) > 0), + CHECK (length(normalized_key) > 0) +); + /* Migration idempotente des positions d'entités déjà enregistrées. */ INSERT OR IGNORE INTO graph_layout_positions(node_id, x, y, updated_at) SELECT entity_id, x, y, updated_at diff --git a/database/schema_v9.sql b/database/schema_v9.sql new file mode 100644 index 0000000..0eb8f2a --- /dev/null +++ b/database/schema_v9.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS relation_types +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE, + label TEXT NOT NULL, + normalized_key TEXT NOT NULL UNIQUE, + description TEXT, + is_system INTEGER NOT NULL DEFAULT 0 CHECK (is_system IN (0, 1)), + CHECK (code IS NULL OR length(trim(code)) > 0), + CHECK (length(trim(label)) > 0), + CHECK (length(normalized_key) > 0) +); + +INSERT OR IGNORE INTO relation_types(code,label,normalized_key,description,is_system) VALUES +('resolves_to','Résout vers','résout vers','Résolution DNS vers une adresse IP.',1), +('aliases_to','Alias de','alias de','Alias DNS canonique.',1), +('uses_name_server','Utilise le serveur de noms','utilise le serveur de noms','Serveur DNS faisant autorité.',1), +('links_to','Lié à','lié à','Lien générique entre deux entités.',1), +('sends','Envoie','envoie','Transmission d’un élément.',1), +('uses','Utilise','utilise','Utilisation d’une ressource ou identité.',1), +('controls','Contrôle','contrôle','Contrôle d’une entité.',1), +('owns','Possède','possède','Possession d’une entité.',1), +('knows','Connaît','connaît','Connaissance entre personnes.',1), +('redirects_to','Redirige vers','redirige vers','Redirection vers une cible.',1); diff --git a/docs/database/DATABASE_ARCHITECTURE.md b/docs/database/DATABASE_ARCHITECTURE.md index 1913482..6666796 100644 --- a/docs/database/DATABASE_ARCHITECTURE.md +++ b/docs/database/DATABASE_ARCHITECTURE.md @@ -2275,6 +2275,32 @@ et vertical du canevas. Ces valeurs sont enregistrées à la fermeture de l'enquête et restaurées lors de son premier chargement, afin que la vue réapparaisse exactement à l'endroit où l'utilisateur l'avait laissée. +## 11.6 Types canoniques de relations + +`relation_types` constitue le référentiel persistant des catégories de +relations. Chaque type possède un identifiant SQLite, un libellé canonique, +une clé Unicode normalisée unique, une description facultative et un +indicateur système. Les types utilisés automatiquement possèdent en plus un +`code` métier stable, indépendant de la langue et du libellé affiché. + +Les quatre notions sont distinctes : + +- le **code métier stable** pilote les producteurs automatiques tels que DNS ; +- le **libellé canonique** est affiché dans le graphe et l’interface ; +- la **clé normalisée** sert uniquement à garantir l’unicité Unicode ; +- le **libellé libre de la relation** décrit le fait précis observé. + +Depuis le schéma V9, `relations.relation_type_id` référence ce référentiel. +L’unicité d’une relation orientée repose sur la source, la cible et cet +identifiant canonique. La colonne historique `type_relation` n’est conservée +que pour la compatibilité des bases antérieures et n’est plus une identité. + +La migration V8 vers V9 parcourt les relations par date puis UUID. Elle +normalise les espaces Unicode, applique NFC et le casefold Unicode, conserve +la première graphie comme libellé des types personnalisés et rattache chaque +relation au type obtenu. Les UUID, métadonnées et tables d’association ne sont +pas reconstruits. Un contrôle `foreign_key_check` précède le COMMIT. + --- # 12. Conclusion diff --git a/include/core/relation_type_normalizer.h b/include/core/relation_type_normalizer.h new file mode 100644 index 0000000..ce07060 --- /dev/null +++ b/include/core/relation_type_normalizer.h @@ -0,0 +1,26 @@ +/****************************************************************************** + * @file relation_type_normalizer.h + * @brief Normalisation métier des libellés de types de relations. + ******************************************************************************/ + +#ifndef LABFY_INVESTIGATION_RELATION_TYPE_NORMALIZER_H +#define LABFY_INVESTIGATION_RELATION_TYPE_NORMALIZER_H + +#include + +G_BEGIN_DECLS + +/** + * @brief Produit la clé d'identité Unicode d'un libellé de type. + * + * La fonction applique NFC, replie toute suite d'espaces Unicode en un espace, + * supprime les espaces périphériques puis effectue un casefold Unicode. + * + * @return Nouvelle chaîne à libérer avec g_free(), ou NULL si le libellé est + * invalide ou vide. + */ +char *relation_type_normalize_key(const char *label); + +G_END_DECLS + +#endif diff --git a/include/core/relation_type_service.h b/include/core/relation_type_service.h new file mode 100644 index 0000000..5dd67f0 --- /dev/null +++ b/include/core/relation_type_service.h @@ -0,0 +1,39 @@ +/****************************************************************************** + * @file relation_type_service.h + * @brief Gestion métier des types canoniques de relations. + ******************************************************************************/ +#ifndef LABFY_INVESTIGATION_RELATION_TYPE_SERVICE_H +#define LABFY_INVESTIGATION_RELATION_TYPE_SERVICE_H +#include "database/database.h" +#include "models/relation_type.h" +#include +G_BEGIN_DECLS +typedef struct RelationTypeService RelationTypeService; +typedef enum +{ + RELATION_TYPE_SERVICE_ERROR_INVALID_ARGUMENT, + RELATION_TYPE_SERVICE_ERROR_DUPLICATE, + RELATION_TYPE_SERVICE_ERROR_IN_USE, + RELATION_TYPE_SERVICE_ERROR_CONFLICT, + RELATION_TYPE_SERVICE_ERROR_DATABASE +} RelationTypeServiceError; +#define RELATION_TYPE_SERVICE_ERROR relation_type_service_error_quark() +GQuark relation_type_service_error_quark(void); +RelationTypeService *relation_type_service_new(Database *database, + GError **error); +void relation_type_service_free(RelationTypeService *service); +GPtrArray *relation_type_service_list_all(RelationTypeService *service, + GError **error); +gboolean relation_type_service_create_custom(RelationTypeService *service, + const char *label, const char *description, gint64 *identifier, + GError **error); +gboolean relation_type_service_rename(RelationTypeService *service, + gint64 identifier, const char *label, GError **error); +gboolean relation_type_service_count_usage(RelationTypeService *service, + gint64 identifier, guint64 *count, GError **error); +gboolean relation_type_service_delete(RelationTypeService *service, + gint64 identifier, GError **error); +gboolean relation_type_service_merge(RelationTypeService *service, + gint64 source_identifier, gint64 target_identifier, GError **error); +G_END_DECLS +#endif diff --git a/include/dao/relation_type_dao.h b/include/dao/relation_type_dao.h new file mode 100644 index 0000000..ff7dc36 --- /dev/null +++ b/include/dao/relation_type_dao.h @@ -0,0 +1,46 @@ +/****************************************************************************** + * @file relation_type_dao.h + * @brief Persistance du référentiel des types de relations. + ******************************************************************************/ +#ifndef LABFY_INVESTIGATION_RELATION_TYPE_DAO_H +#define LABFY_INVESTIGATION_RELATION_TYPE_DAO_H + +#include "database/database.h" +#include "models/relation_type.h" +#include + +G_BEGIN_DECLS +typedef struct RelationTypeDao RelationTypeDao; +typedef enum +{ + RELATION_TYPE_DAO_ERROR_INVALID_ARGUMENT, + RELATION_TYPE_DAO_ERROR_PREPARE, + RELATION_TYPE_DAO_ERROR_BIND, + RELATION_TYPE_DAO_ERROR_EXECUTE, + RELATION_TYPE_DAO_ERROR_CONSTRAINT, + RELATION_TYPE_DAO_ERROR_READ, + RELATION_TYPE_DAO_ERROR_MODEL +} RelationTypeDaoError; +#define RELATION_TYPE_DAO_ERROR relation_type_dao_error_quark() +GQuark relation_type_dao_error_quark(void); +RelationTypeDao *relation_type_dao_new(Database *database, GError **error); +void relation_type_dao_free(RelationTypeDao *dao); +GPtrArray *relation_type_dao_list_all(RelationTypeDao *dao, GError **error); +RelationType *relation_type_dao_find_by_identifier(RelationTypeDao *dao, + gint64 identifier, GError **error); +RelationType *relation_type_dao_find_by_code(RelationTypeDao *dao, + const char *code, GError **error); +RelationType *relation_type_dao_find_by_normalized_key(RelationTypeDao *dao, + const char *key, GError **error); +gboolean relation_type_dao_insert_custom(RelationTypeDao *dao, + const char *label, const char *normalized_key, const char *description, + gint64 *out_identifier, GError **error); +gboolean relation_type_dao_update_label(RelationTypeDao *dao, + gint64 identifier, const char *label, const char *normalized_key, + GError **error); +gboolean relation_type_dao_count_relations(RelationTypeDao *dao, + gint64 identifier, guint64 *count, GError **error); +gboolean relation_type_dao_delete_unused(RelationTypeDao *dao, + gint64 identifier, GError **error); +G_END_DECLS +#endif diff --git a/include/database/schema.h b/include/database/schema.h index 5219848..bf17842 100644 --- a/include/database/schema.h +++ b/include/database/schema.h @@ -89,6 +89,7 @@ bool schema_install_v6(Database *database); bool schema_install_v7(Database *database); bool schema_install_v8(Database *database); +bool schema_install_v9(Database *database); /** * @brief Garantit la présence des extensions du schéma courant V2. diff --git a/include/models/relation_record.h b/include/models/relation_record.h index 74872ae..3ff10b4 100644 --- a/include/models/relation_record.h +++ b/include/models/relation_record.h @@ -99,6 +99,21 @@ RelationRecord *relation_record_new( GError **error ); +RelationRecord *relation_record_new_canonical( + const char *identifier, + const char *source_entity_identifier, + const char *target_entity_identifier, + gint64 relation_type_identifier, + const char *relation_type_code, + const char *relation_type_label, + const char *label, + const char *justification, + gint confidence, + const char *created_at, + const char *updated_at, + RelationStatus status, + GError **error); + /** * @brief Libère une relation. * @@ -135,6 +150,12 @@ const char *relation_record_get_target_entity_identifier( const char *relation_record_get_relation_type( const RelationRecord *relation_record ); +gint64 relation_record_get_relation_type_identifier( + const RelationRecord *relation_record); +const char *relation_record_get_relation_type_code( + const RelationRecord *relation_record); +const char *relation_record_get_relation_type_label( + const RelationRecord *relation_record); /** * @brief Retourne le libellé facultatif. diff --git a/include/models/relation_type.h b/include/models/relation_type.h new file mode 100644 index 0000000..179d0ca --- /dev/null +++ b/include/models/relation_type.h @@ -0,0 +1,38 @@ +/****************************************************************************** + * @file relation_type.h + * @brief Type canonique d'une relation. + ******************************************************************************/ + +#ifndef LABFY_INVESTIGATION_RELATION_TYPE_H +#define LABFY_INVESTIGATION_RELATION_TYPE_H + +#include + +G_BEGIN_DECLS + +typedef struct RelationType RelationType; + +typedef enum +{ + RELATION_TYPE_ERROR_INVALID_ARGUMENT, + RELATION_TYPE_ERROR_INVALID_IDENTIFIER, + RELATION_TYPE_ERROR_INVALID_LABEL, + RELATION_TYPE_ERROR_INVALID_NORMALIZED_KEY +} RelationTypeError; + +#define RELATION_TYPE_ERROR relation_type_error_quark() +GQuark relation_type_error_quark(void); + +RelationType *relation_type_new(gint64 identifier, const char *code, + const char *label, const char *normalized_key, const char *description, + gboolean system_type, GError **error); +void relation_type_free(RelationType *relation_type); +gint64 relation_type_get_identifier(const RelationType *relation_type); +const char *relation_type_get_code(const RelationType *relation_type); +const char *relation_type_get_label(const RelationType *relation_type); +const char *relation_type_get_normalized_key(const RelationType *relation_type); +const char *relation_type_get_description(const RelationType *relation_type); +gboolean relation_type_is_system(const RelationType *relation_type); + +G_END_DECLS +#endif diff --git a/include/views/create_relation_dialog.h b/include/views/create_relation_dialog.h index 6d3d6ff..704d26b 100644 --- a/include/views/create_relation_dialog.h +++ b/include/views/create_relation_dialog.h @@ -80,6 +80,7 @@ gboolean create_relation_dialog_present( GtkWindow *parent, const char *source_entity_identifier, const GPtrArray *entities, + const GPtrArray *relation_types, const GPtrArray *evidence_records, const char *investigation_root_path, CreateRelationDialogCallback callback, @@ -109,6 +110,7 @@ gboolean edit_relation_dialog_present( GtkWindow *parent, const RelationRecord *relation_record, const GPtrArray *entities, + const GPtrArray *relation_types, const GPtrArray *evidence_records, const GPtrArray *selected_evidence_identifiers, const char *investigation_root_path, @@ -168,6 +170,10 @@ const char *create_relation_dialog_result_get_target_identifier( const char *create_relation_dialog_result_get_relation_type( const CreateRelationDialogResult *result ); +gint64 create_relation_dialog_result_get_relation_type_identifier( + const CreateRelationDialogResult *result); +const char *create_relation_dialog_result_get_relation_type_code( + const CreateRelationDialogResult *result); /** * @brief Retourne le libellé facultatif. diff --git a/src/core/application.c b/src/core/application.c index 687e1ff..e5e2602 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -33,6 +33,9 @@ #include "models/evidence_record.h" #include "dao/evidence_type_dao.h" #include "dao/graph_node_position_dao.h" +#include "dao/relation_type_dao.h" +#include "models/relation_type.h" +#include "core/relation_type_service.h" #include "views/evidence_import_dialog.h" #include "views/evidence_metadata_dialog.h" #include "views/create_relation_dialog.h" @@ -5593,6 +5596,7 @@ static void application_on_create_relation_completed( gboolean relation_created = FALSE; + gint64 relation_type_identifier = 0; if (application == NULL || application->main_window == NULL) @@ -5707,11 +5711,33 @@ static void application_on_create_relation_completed( goto cleanup; } + relation_type_identifier = + create_relation_dialog_result_get_relation_type_identifier(result); + if (relation_type_identifier == 0) + { + RelationTypeService *type_service = + relation_type_service_new(database, &error); + if (type_service == NULL || + !relation_type_service_create_custom(type_service, relation_type, + NULL, &relation_type_identifier, &error)) + { + relation_type_service_free(type_service); + application_present_error(application, + "Création du type impossible", + error != NULL ? error->message : + "Le nouveau type n'a pas pu être créé."); + goto cleanup; + } + relation_type_service_free(type_service); + } + relation_record = - relation_record_new( + relation_record_new_canonical( relation_identifier, source_identifier, target_identifier, + relation_type_identifier, + create_relation_dialog_result_get_relation_type_code(result), relation_type, create_relation_dialog_result_get_label( result @@ -6240,6 +6266,7 @@ static void application_on_edit_relation_completed( char *timestamp = NULL; GError *error = NULL; gboolean transaction_active = FALSE; + gint64 relation_type_identifier = 0; if (result == NULL || application == NULL || application->session == NULL) goto cleanup; @@ -6254,10 +6281,28 @@ static void application_on_edit_relation_completed( now = g_date_time_new_now_utc(); timestamp = now != NULL ? g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ") : NULL; - updated = relation_record_new( + relation_type_identifier = + create_relation_dialog_result_get_relation_type_identifier(result); + if (relation_type_identifier == 0) + { + RelationTypeService *type_service = + relation_type_service_new(database, &error); + if (type_service == NULL || + !relation_type_service_create_custom(type_service, + create_relation_dialog_result_get_relation_type(result), + NULL, &relation_type_identifier, &error)) + { + relation_type_service_free(type_service); + goto failure; + } + relation_type_service_free(type_service); + } + updated = relation_record_new_canonical( relation_record_get_identifier(existing), relation_record_get_source_entity_identifier(existing), relation_record_get_target_entity_identifier(existing), + relation_type_identifier, + create_relation_dialog_result_get_relation_type_code(result), create_relation_dialog_result_get_relation_type(result), create_relation_dialog_result_get_label(result), create_relation_dialog_result_get_justification(result), @@ -6348,6 +6393,8 @@ static void application_on_edit_relation_requested( GPtrArray *entities = NULL; GPtrArray *evidences = NULL; GPtrArray *selected_evidences = NULL; + GPtrArray *relation_types = NULL; + RelationTypeDao *relation_type_dao = NULL; const InvestigationProject *project = NULL; ApplicationEditRelationContext *context = NULL; GError *error = NULL; @@ -6359,6 +6406,7 @@ static void application_on_edit_relation_requested( entity_dao = entity_dao_new(database, &error); evidence_dao = evidence_dao_new(database, &error); relation_evidence_dao = relation_evidence_dao_new(database, &error); + relation_type_dao = relation_type_dao_new(database, &error); project = investigation_session_get_project(application->session); if (relation_dao != NULL) relation = relation_dao_find_by_identifier(relation_dao, @@ -6369,13 +6417,17 @@ static void application_on_edit_relation_requested( if (relation_evidence_dao != NULL) selected_evidences = relation_evidence_dao_list_evidence_identifiers(relation_evidence_dao, relation_identifier, &error); + if (relation_type_dao != NULL) + relation_types = relation_type_dao_list_all(relation_type_dao, &error); if (relation == NULL || entities == NULL || evidences == NULL || - selected_evidences == NULL || project == NULL) goto failure; + selected_evidences == NULL || relation_types == NULL || + project == NULL) goto failure; context = g_new0(ApplicationEditRelationContext, 1); context->application = application; context->relation_identifier = g_strdup(relation_identifier); if (context->relation_identifier == NULL || !edit_relation_dialog_present( main_window_get_window(application->main_window), relation, entities, + relation_types, evidences, selected_evidences, investigation_project_get_root_path(project), application_on_edit_relation_completed, context, &error)) @@ -6396,6 +6448,8 @@ cleanup: entity_dao_free(entity_dao); evidence_dao_free(evidence_dao); relation_evidence_dao_free(relation_evidence_dao); + relation_type_dao_free(relation_type_dao); + g_clear_pointer(&relation_types, g_ptr_array_unref); relation_dao_free(relation_dao); } @@ -6462,11 +6516,13 @@ static void application_on_add_relation_requested( NULL; EvidenceDao *evidence_dao = NULL; + RelationTypeDao *relation_type_dao = NULL; const InvestigationProject *project = NULL; GPtrArray *entities = NULL; GPtrArray *evidences = NULL; + GPtrArray *relation_types = NULL; GError *error = NULL; @@ -6533,11 +6589,17 @@ static void application_on_add_relation_requested( evidences = evidence_dao_list_all(evidence_dao, &error); evidence_dao_free(evidence_dao); + relation_type_dao = relation_type_dao_new(database, &error); + if (relation_type_dao != NULL) + relation_types = relation_type_dao_list_all(relation_type_dao, &error); + relation_type_dao_free(relation_type_dao); + entity_dao_free( entity_dao ); - if (entities == NULL || evidences == NULL || project == NULL) + if (entities == NULL || evidences == NULL || relation_types == NULL || + project == NULL) { application_present_error( application, @@ -6553,6 +6615,7 @@ static void application_on_add_relation_requested( g_clear_pointer(&entities, g_ptr_array_unref); g_clear_pointer(&evidences, g_ptr_array_unref); + g_clear_pointer(&relation_types, g_ptr_array_unref); return; } @@ -6563,6 +6626,7 @@ static void application_on_add_relation_requested( ), source_entity_identifier, entities, + relation_types, evidences, investigation_project_get_root_path(project), application_on_create_relation_completed, @@ -6587,6 +6651,7 @@ static void application_on_add_relation_requested( entities ); g_ptr_array_unref(evidences); + g_ptr_array_unref(relation_types); } /** diff --git a/src/core/osint_dns_integration.c b/src/core/osint_dns_integration.c index c02e2c6..35362c9 100644 --- a/src/core/osint_dns_integration.c +++ b/src/core/osint_dns_integration.c @@ -183,7 +183,9 @@ gboolean osint_dns_integration_apply( osint_dns_integration_build_relation_key( relation_record_get_source_entity_identifier(relation), relation_record_get_target_entity_identifier(relation), - relation_record_get_relation_type(relation) + relation_record_get_relation_type_code(relation) != NULL + ? relation_record_get_relation_type_code(relation) + : relation_record_get_relation_type(relation) ), g_strdup(relation_record_get_identifier(relation)) ); diff --git a/src/core/relation_type_normalizer.c b/src/core/relation_type_normalizer.c new file mode 100644 index 0000000..1c2bd7b --- /dev/null +++ b/src/core/relation_type_normalizer.c @@ -0,0 +1,47 @@ +/****************************************************************************** + * @file relation_type_normalizer.c + * @brief Normalisation Unicode des types de relations. + ******************************************************************************/ + +#include "core/relation_type_normalizer.h" + +char *relation_type_normalize_key(const char *label) +{ + char *normalized = NULL; + char *folded = NULL; + GString *collapsed = NULL; + const char *cursor = NULL; + gboolean pending_space = FALSE; + + if (label == NULL || !g_utf8_validate(label, -1, NULL)) return NULL; + normalized = g_utf8_normalize(label, -1, G_NORMALIZE_DEFAULT_COMPOSE); + if (normalized == NULL) return NULL; + collapsed = g_string_new(NULL); + for (cursor = normalized; *cursor != '\0'; cursor = g_utf8_next_char(cursor)) + { + gunichar character = g_utf8_get_char(cursor); + if (g_unichar_isspace(character)) + { + if (collapsed->len > 0U) pending_space = TRUE; + continue; + } + if (pending_space) + { + g_string_append_c(collapsed, ' '); + pending_space = FALSE; + } + g_string_append_unichar(collapsed, character); + } + g_free(normalized); + if (collapsed->len == 0U) + { + g_string_free(collapsed, TRUE); + return NULL; + } + folded = g_utf8_casefold(collapsed->str, -1); + g_string_free(collapsed, TRUE); + if (folded == NULL) return NULL; + normalized = g_utf8_normalize(folded, -1, G_NORMALIZE_DEFAULT_COMPOSE); + g_free(folded); + return normalized; +} diff --git a/src/core/relation_type_service.c b/src/core/relation_type_service.c new file mode 100644 index 0000000..e028532 --- /dev/null +++ b/src/core/relation_type_service.c @@ -0,0 +1,152 @@ +#include "core/relation_type_service.h" +#include "core/relation_type_normalizer.h" +#include "dao/relation_type_dao.h" +#include "database/error.h" +#include "database/statement.h" +#include "database/transaction.h" + +struct RelationTypeService +{ + Database *database; + RelationTypeDao *dao; +}; + +static void set_error(GError **error, RelationTypeServiceError code, + const char *message) +{ + if (error != NULL && *error == NULL) + g_set_error_literal(error, RELATION_TYPE_SERVICE_ERROR, code, message); +} +GQuark relation_type_service_error_quark(void) +{ return g_quark_from_static_string("relation-type-service-error-quark"); } +RelationTypeService *relation_type_service_new(Database *database, + GError **error) +{ + RelationTypeService *service = NULL; + if (database == NULL) { set_error(error, + RELATION_TYPE_SERVICE_ERROR_INVALID_ARGUMENT, + "La base est obligatoire."); return NULL; } + service = g_new0(RelationTypeService, 1); + service->database = database; + service->dao = relation_type_dao_new(database, error); + if (service->dao == NULL) { g_free(service); return NULL; } + return service; +} +void relation_type_service_free(RelationTypeService *service) +{ + if (service == NULL) return; + relation_type_dao_free(service->dao); g_free(service); +} +GPtrArray *relation_type_service_list_all(RelationTypeService *service, + GError **error) +{ return service != NULL ? relation_type_dao_list_all(service->dao, error) : NULL; } + +gboolean relation_type_service_create_custom(RelationTypeService *service, + const char *label, const char *description, gint64 *identifier, + GError **error) +{ + char *key = relation_type_normalize_key(label); + RelationType *existing = NULL; gboolean result = FALSE; + if (service == NULL || key == NULL) { set_error(error, + RELATION_TYPE_SERVICE_ERROR_INVALID_ARGUMENT, + "Le libellé du type est invalide."); goto cleanup; } + existing = relation_type_dao_find_by_normalized_key(service->dao,key,error); + if (existing != NULL) { set_error(error, RELATION_TYPE_SERVICE_ERROR_DUPLICATE, + "Un type de relation équivalent existe déjà."); goto cleanup; } + if (error != NULL && *error != NULL) goto cleanup; + result = relation_type_dao_insert_custom(service->dao,label,key,description, + identifier,error); +cleanup: + relation_type_free(existing); g_free(key); return result; +} + +gboolean relation_type_service_rename(RelationTypeService *service, + gint64 identifier, const char *label, GError **error) +{ + char *key = relation_type_normalize_key(label); + RelationType *existing = NULL; gboolean result = FALSE; + if (service == NULL || identifier <= 0 || key == NULL) { set_error(error, + RELATION_TYPE_SERVICE_ERROR_INVALID_ARGUMENT, + "Le renommage demandé est invalide."); goto cleanup; } + existing = relation_type_dao_find_by_normalized_key(service->dao,key,error); + if (existing != NULL && + relation_type_get_identifier(existing) != identifier) + { set_error(error, RELATION_TYPE_SERVICE_ERROR_DUPLICATE, + "Un type de relation équivalent existe déjà."); goto cleanup; } + if (error != NULL && *error != NULL) goto cleanup; + result = relation_type_dao_update_label(service->dao,identifier,label,key,error); +cleanup: + relation_type_free(existing); g_free(key); return result; +} + +gboolean relation_type_service_count_usage(RelationTypeService *service, + gint64 identifier, guint64 *count, GError **error) +{ + if (service == NULL || identifier <= 0 || count == NULL) { set_error(error, + RELATION_TYPE_SERVICE_ERROR_INVALID_ARGUMENT, + "Le comptage demandé est invalide."); return FALSE; } + return relation_type_dao_count_relations(service->dao,identifier,count,error); +} + +gboolean relation_type_service_delete(RelationTypeService *service, + gint64 identifier, GError **error) +{ + guint64 count = 0; + if (!relation_type_service_count_usage(service,identifier,&count,error)) + return FALSE; + if (count > 0U) { set_error(error, RELATION_TYPE_SERVICE_ERROR_IN_USE, + "Ce type est utilisé par des relations et ne peut pas être supprimé."); + return FALSE; } + return relation_type_dao_delete_unused(service->dao,identifier,error); +} + +gboolean relation_type_service_merge(RelationTypeService *service, + gint64 source_identifier, gint64 target_identifier, GError **error) +{ + DatabaseStatement *statement = NULL; gboolean active = FALSE; + gboolean success = FALSE; + if (service == NULL || source_identifier <= 0 || target_identifier <= 0 || + source_identifier == target_identifier) + { set_error(error, RELATION_TYPE_SERVICE_ERROR_INVALID_ARGUMENT, + "La fusion demandée est invalide."); return FALSE; } + statement = database_statement_prepare(service->database, + "SELECT 1 FROM relations source JOIN relations target ON " + "target.entite_source_id=source.entite_source_id AND " + "target.entite_cible_id=source.entite_cible_id AND " + "target.relation_type_id=? WHERE source.relation_type_id=? LIMIT 1;"); + if (statement == NULL || + !database_statement_bind_int64(statement,1,target_identifier) || + !database_statement_bind_int64(statement,2,source_identifier)) + goto cleanup; + if (database_statement_step(statement) == DATABASE_STATEMENT_STEP_ROW) + { set_error(error, RELATION_TYPE_SERVICE_ERROR_CONFLICT, + "Fusion refusée : des relations source et cible entreraient en conflit."); + goto cleanup; } + database_statement_finalize(statement); statement = NULL; + if (!database_transaction_begin(service->database)) goto cleanup; + active = TRUE; + statement = database_statement_prepare(service->database, + "UPDATE relations SET relation_type_id=?,type_relation=(" + "SELECT COALESCE(code,label) FROM relation_types WHERE id=?) " + "WHERE relation_type_id=?;"); + if (statement == NULL || + !database_statement_bind_int64(statement,1,target_identifier) || + !database_statement_bind_int64(statement,2,target_identifier) || + !database_statement_bind_int64(statement,3,source_identifier) || + database_statement_step(statement) != DATABASE_STATEMENT_STEP_DONE) + goto cleanup; + database_statement_finalize(statement); statement = NULL; + if (!relation_type_dao_delete_unused(service->dao,source_identifier,error)) + goto cleanup; + if (!database_transaction_commit(service->database)) goto cleanup; + active = FALSE; success = TRUE; +cleanup: + database_statement_finalize(statement); + if (!success && active) (void) database_transaction_rollback(service->database); + if (!success && error != NULL && *error == NULL) + g_set_error(error, RELATION_TYPE_SERVICE_ERROR, + RELATION_TYPE_SERVICE_ERROR_DATABASE, "Fusion impossible : %s", + database_error_get_message(service->database) != NULL + ? database_error_get_message(service->database) : "erreur SQLite"); + return success; +} diff --git a/src/dao/relation_dao.c b/src/dao/relation_dao.c index a49ca33..4442a08 100644 --- a/src/dao/relation_dao.c +++ b/src/dao/relation_dao.c @@ -7,6 +7,8 @@ #include "database/error.h" #include "database/statement.h" +#include "dao/relation_type_dao.h" +#include "core/relation_type_normalizer.h" #include #include @@ -31,6 +33,7 @@ static const char *const relation_dao_insert_sql = " entite_source_id," " entite_cible_id," " type_relation," + " relation_type_id," " label," " justification," " confiance," @@ -49,14 +52,15 @@ static const char *const relation_dao_insert_sql = " ?," " ?," " ?," + " ?," " ?" ");"; /** @brief Requête de modification des attributs d'une relation. */ static const char *const relation_dao_update_sql = "UPDATE relations SET " - "type_relation = ?, label = ?, justification = ?, confiance = ?, " - "updated_at = ?, status = ? WHERE id = ?;"; + "type_relation = ?, relation_type_id = ?, label = ?, justification = ?, " + "confiance = ?, updated_at = ?, status = ? WHERE id = ?;"; /** @brief Requête de suppression d'une relation. */ static const char *const relation_dao_delete_sql = @@ -67,38 +71,42 @@ static const char *const relation_dao_delete_sql = */ static const char *const relation_dao_find_by_identifier_sql = "SELECT " - " id," - " entite_source_id," - " entite_cible_id," - " type_relation," - " label," - " justification," - " confiance," - " created_at," - " updated_at," - " status " - "FROM relations " - "WHERE id = ?;"; + " r.id," + " r.entite_source_id," + " r.entite_cible_id," + " rt.id," + " rt.code," + " rt.label," + " r.label," + " r.justification," + " r.confiance," + " r.created_at," + " r.updated_at," + " r.status " + "FROM relations r JOIN relation_types rt ON rt.id=r.relation_type_id " + "WHERE r.id = ?;"; /** * @brief Requête de chargement de toutes les relations. */ static const char *const relation_dao_list_all_sql = "SELECT " - " id," - " entite_source_id," - " entite_cible_id," - " type_relation," - " label," - " justification," - " confiance," - " created_at," - " updated_at," - " status " - "FROM relations " + " r.id," + " r.entite_source_id," + " r.entite_cible_id," + " rt.id," + " rt.code," + " rt.label," + " r.label," + " r.justification," + " r.confiance," + " r.created_at," + " r.updated_at," + " r.status " + "FROM relations r JOIN relation_types rt ON rt.id=r.relation_type_id " "ORDER BY " - " created_at ASC," - " id ASC;"; + " r.created_at ASC," + " r.id ASC;"; /** * @brief Requête de comptage. @@ -133,7 +141,7 @@ static const char *const relation_dao_duplicate_exists_sql = "FROM relations " "WHERE entite_source_id = ? " "AND entite_cible_id = ? " - "AND type_relation = ? " + "AND relation_type_id = ? " "LIMIT 1;"; /** @@ -492,7 +500,7 @@ static gboolean relation_dao_duplicate_exists( RelationDao *relation_dao, const char *source_entity_identifier, const char *target_entity_identifier, - const char *relation_type, + gint64 relation_type_identifier, gboolean *out_exists, GError **error ) @@ -510,7 +518,7 @@ static gboolean relation_dao_duplicate_exists( relation_dao->database == NULL || source_entity_identifier == NULL || target_entity_identifier == NULL || - relation_type == NULL || + relation_type_identifier <= 0 || out_exists == NULL) { relation_dao_set_error_literal( @@ -553,10 +561,10 @@ static gboolean relation_dao_duplicate_exists( 2, target_entity_identifier ) || - !database_statement_bind_text( + !database_statement_bind_int64( statement, 3, - relation_type + relation_type_identifier )) { relation_dao_set_database_error( @@ -626,6 +634,8 @@ static RelationRecord *relation_dao_read_current_record( char *relation_type = NULL; + char *relation_type_code = NULL; + int64_t relation_type_identifier = 0; char *label = NULL; @@ -679,39 +689,49 @@ static RelationRecord *relation_dao_read_current_record( 2, &target_entity_identifier ) || - !database_statement_column_text( + !database_statement_column_int64( statement, 3, - &relation_type + &relation_type_identifier ) || !database_statement_column_text( statement, 4, - &label + &relation_type_code ) || !database_statement_column_text( statement, 5, - &justification + &relation_type ) || - !database_statement_column_int64( + !database_statement_column_text( statement, 6, - &confidence_value + &label ) || !database_statement_column_text( statement, 7, - &created_at + &justification ) || - !database_statement_column_text( + !database_statement_column_int64( statement, 8, - &updated_at + &confidence_value ) || !database_statement_column_text( statement, 9, + &created_at + ) || + !database_statement_column_text( + statement, + 10, + &updated_at + ) || + !database_statement_column_text( + statement, + 11, &status_text )) { @@ -751,10 +771,12 @@ static RelationRecord *relation_dao_read_current_record( } relation_record = - relation_record_new( + relation_record_new_canonical( identifier, source_entity_identifier, target_entity_identifier, + (gint64) relation_type_identifier, + relation_type_code, relation_type, label, justification, @@ -816,6 +838,7 @@ cleanup: g_free( relation_type ); + g_free(relation_type_code); g_free( target_entity_identifier @@ -915,6 +938,36 @@ void relation_dao_free( ); } +static RelationType *relation_dao_resolve_type( + RelationDao *relation_dao, const RelationRecord *record, GError **error) +{ + RelationTypeDao *type_dao = NULL; + RelationType *type = NULL; + char *key = NULL; + gint64 identifier = relation_record_get_relation_type_identifier(record); + type_dao = relation_type_dao_new(relation_dao->database, error); + if (type_dao == NULL) return NULL; + if (identifier > 0) + type = relation_type_dao_find_by_identifier(type_dao, identifier, error); + else if (relation_record_get_relation_type_code(record) != NULL) + type = relation_type_dao_find_by_code(type_dao, + relation_record_get_relation_type_code(record), error); + else + { + key = relation_type_normalize_key( + relation_record_get_relation_type(record)); + if (key != NULL) + type = relation_type_dao_find_by_code(type_dao, + relation_record_get_relation_type(record), error); + if (type == NULL && (error == NULL || *error == NULL)) + type = relation_type_dao_find_by_normalized_key(type_dao, key, error); + } + if (type == NULL && error != NULL && *error == NULL) + relation_dao_set_error_literal(error, RELATION_DAO_ERROR_CONSTRAINT, + "Le type de relation n'existe pas dans le référentiel."); + g_free(key); relation_type_dao_free(type_dao); return type; +} + gboolean relation_dao_insert( RelationDao *relation_dao, const RelationRecord *relation_record, @@ -962,6 +1015,9 @@ gboolean relation_dao_insert( gboolean success = FALSE; + RelationType *canonical_type = NULL; + gint64 canonical_type_identifier = 0; + const char *canonical_type_label = NULL; g_return_val_if_fail( error == NULL || *error == NULL, @@ -1161,16 +1217,24 @@ gboolean relation_dao_insert( return FALSE; } + canonical_type = relation_dao_resolve_type( + relation_dao, relation_record, error); + if (canonical_type == NULL) return FALSE; + canonical_type_identifier = relation_type_get_identifier(canonical_type); + canonical_type_label = relation_type_get_code(canonical_type) != NULL + ? relation_type_get_code(canonical_type) + : relation_type_get_label(canonical_type); + if (!relation_dao_duplicate_exists( relation_dao, source_entity_identifier, target_entity_identifier, - relation_type, + canonical_type_identifier, &value_exists, error )) { - return FALSE; + goto cleanup; } if (value_exists) @@ -1181,7 +1245,7 @@ gboolean relation_dao_insert( "Cette relation orientée existe déjà pour ce type." ); - return FALSE; + goto cleanup; } statement = @@ -1220,36 +1284,41 @@ gboolean relation_dao_insert( !database_statement_bind_text( statement, 4, - relation_type + canonical_type_label ) || - !relation_dao_bind_optional_text( + !database_statement_bind_int64( statement, 5, - label + canonical_type_identifier ) || !relation_dao_bind_optional_text( statement, 6, + label + ) || + !relation_dao_bind_optional_text( + statement, + 7, justification ) || !database_statement_bind_int64( statement, - 7, + 8, (int64_t) confidence ) || !database_statement_bind_text( statement, - 8, + 9, created_at ) || !database_statement_bind_text( statement, - 9, + 10, updated_at ) || !database_statement_bind_text( statement, - 10, + 11, status_text )) { @@ -1304,6 +1373,7 @@ cleanup: database_statement_finalize( statement ); + relation_type_free(canonical_type); return success; } @@ -1314,6 +1384,7 @@ gboolean relation_dao_update(RelationDao *relation_dao, DatabaseStatement *statement = NULL; const char *status_text = NULL; gboolean success = FALSE; + RelationType *canonical_type = NULL; g_return_val_if_fail(error == NULL || *error == NULL, FALSE); if (relation_dao == NULL || relation_record == NULL) @@ -1325,6 +1396,9 @@ gboolean relation_dao_update(RelationDao *relation_dao, } status_text = relation_dao_status_to_text( relation_record_get_status(relation_record)); + canonical_type = relation_dao_resolve_type( + relation_dao, relation_record, error); + if (canonical_type == NULL) return FALSE; statement = database_statement_prepare(relation_dao->database, relation_dao_update_sql); if (statement == NULL) @@ -1332,21 +1406,25 @@ gboolean relation_dao_update(RelationDao *relation_dao, relation_dao_set_database_error(relation_dao, error, RELATION_DAO_ERROR_PREPARE, "Impossible de préparer la modification de la relation"); - return FALSE; + goto cleanup; } if (!database_statement_bind_text(statement, 1, - relation_record_get_relation_type(relation_record)) || - !relation_dao_bind_optional_text(statement, 2, - relation_record_get_label(relation_record)) || + relation_type_get_code(canonical_type) != NULL + ? relation_type_get_code(canonical_type) + : relation_type_get_label(canonical_type)) || + !database_statement_bind_int64(statement, 2, + relation_type_get_identifier(canonical_type)) || !relation_dao_bind_optional_text(statement, 3, + relation_record_get_label(relation_record)) || + !relation_dao_bind_optional_text(statement, 4, relation_record_get_justification(relation_record)) || - !database_statement_bind_int64(statement, 4, + !database_statement_bind_int64(statement, 5, relation_record_get_confidence(relation_record)) || - !database_statement_bind_text(statement, 5, + !database_statement_bind_text(statement, 6, relation_record_get_updated_at(relation_record)) || status_text == NULL || - !database_statement_bind_text(statement, 6, status_text) || - !database_statement_bind_text(statement, 7, + !database_statement_bind_text(statement, 7, status_text) || + !database_statement_bind_text(statement, 8, relation_record_get_identifier(relation_record))) { relation_dao_set_database_error(relation_dao, error, @@ -1364,6 +1442,7 @@ gboolean relation_dao_update(RelationDao *relation_dao, success = TRUE; cleanup: database_statement_finalize(statement); + relation_type_free(canonical_type); return success; } diff --git a/src/dao/relation_type_dao.c b/src/dao/relation_type_dao.c new file mode 100644 index 0000000..95f02c9 --- /dev/null +++ b/src/dao/relation_type_dao.c @@ -0,0 +1,189 @@ +#include "dao/relation_type_dao.h" +#include "database/error.h" +#include "database/statement.h" + +struct RelationTypeDao { Database *database; }; + +static void set_db_error(RelationTypeDao *dao, GError **error, + RelationTypeDaoError code, const char *context) +{ + if (error != NULL && *error == NULL) + g_set_error(error, RELATION_TYPE_DAO_ERROR, code, "%s : %s", context, + dao != NULL && database_error_get_message(dao->database) != NULL + ? database_error_get_message(dao->database) : "erreur inconnue"); +} + +static RelationType *read_type(DatabaseStatement *statement, GError **error) +{ + int64_t id = 0; int64_t system = 0; + char *code = NULL; char *label = NULL; char *key = NULL; char *description = NULL; + RelationType *type = NULL; GError *model_error = NULL; + if (!database_statement_column_int64(statement, 0, &id) || + !database_statement_column_text(statement, 1, &code) || + !database_statement_column_text(statement, 2, &label) || + !database_statement_column_text(statement, 3, &key) || + !database_statement_column_text(statement, 4, &description) || + !database_statement_column_int64(statement, 5, &system)) + { + g_set_error_literal(error, RELATION_TYPE_DAO_ERROR, + RELATION_TYPE_DAO_ERROR_READ, "Le type de relation est illisible."); + goto cleanup; + } + type = relation_type_new((gint64) id, code, label, key, description, + system != 0, &model_error); + if (type == NULL) + g_set_error(error, RELATION_TYPE_DAO_ERROR, + RELATION_TYPE_DAO_ERROR_MODEL, "Type de relation invalide : %s", + model_error != NULL ? model_error->message : "erreur inconnue"); +cleanup: + g_clear_error(&model_error); g_free(description); g_free(key); + g_free(label); g_free(code); return type; +} + +static RelationType *find_one(RelationTypeDao *dao, const char *sql, + gboolean bind_integer, gint64 integer, const char *text, GError **error) +{ + DatabaseStatement *statement = NULL; RelationType *type = NULL; + if (dao == NULL || dao->database == NULL) return NULL; + statement = database_statement_prepare(dao->database, sql); + if (statement == NULL) { set_db_error(dao, error, + RELATION_TYPE_DAO_ERROR_PREPARE, "Lecture du type impossible"); return NULL; } + if ((bind_integer && !database_statement_bind_int64(statement, 1, integer)) || + (!bind_integer && !database_statement_bind_text(statement, 1, text))) + { set_db_error(dao, error, RELATION_TYPE_DAO_ERROR_BIND, + "Paramètre du type invalide"); goto cleanup; } + DatabaseStatementStepResult step = database_statement_step(statement); + if (step == DATABASE_STATEMENT_STEP_ROW) type = read_type(statement, error); + else if (step == DATABASE_STATEMENT_STEP_ERROR) set_db_error(dao, error, + RELATION_TYPE_DAO_ERROR_EXECUTE, "Recherche du type impossible"); +cleanup: + database_statement_finalize(statement); return type; +} + +GQuark relation_type_dao_error_quark(void) +{ return g_quark_from_static_string("relation-type-dao-error-quark"); } +RelationTypeDao *relation_type_dao_new(Database *database, GError **error) +{ + if (database == NULL) { g_set_error_literal(error, RELATION_TYPE_DAO_ERROR, + RELATION_TYPE_DAO_ERROR_INVALID_ARGUMENT, "La base est obligatoire."); + return NULL; } + RelationTypeDao *dao = g_new0(RelationTypeDao, 1); dao->database = database; + return dao; +} +void relation_type_dao_free(RelationTypeDao *dao) { g_free(dao); } + +GPtrArray *relation_type_dao_list_all(RelationTypeDao *dao, GError **error) +{ + static const char *sql = "SELECT id,code,label,normalized_key,description," + "is_system FROM relation_types ORDER BY label COLLATE NOCASE,id;"; + DatabaseStatement *statement = NULL; + GPtrArray *items = g_ptr_array_new_with_free_func( + (GDestroyNotify) relation_type_free); + if (dao == NULL || dao->database == NULL) goto fail; + statement = database_statement_prepare(dao->database, sql); + if (statement == NULL) { set_db_error(dao, error, + RELATION_TYPE_DAO_ERROR_PREPARE, "Liste des types impossible"); goto fail; } + while (TRUE) + { + DatabaseStatementStepResult step = database_statement_step(statement); + if (step == DATABASE_STATEMENT_STEP_DONE) break; + if (step == DATABASE_STATEMENT_STEP_ERROR) { set_db_error(dao, error, + RELATION_TYPE_DAO_ERROR_EXECUTE, "Liste des types impossible"); goto fail; } + RelationType *type = read_type(statement, error); + if (type == NULL) goto fail; + g_ptr_array_add(items, type); + } + database_statement_finalize(statement); return items; +fail: + database_statement_finalize(statement); g_ptr_array_unref(items); return NULL; +} + +#define TYPE_COLUMNS "SELECT id,code,label,normalized_key,description,is_system FROM relation_types WHERE " +RelationType *relation_type_dao_find_by_identifier(RelationTypeDao *dao, + gint64 id, GError **error) +{ return find_one(dao, TYPE_COLUMNS "id=?;", TRUE, id, NULL, error); } +RelationType *relation_type_dao_find_by_code(RelationTypeDao *dao, + const char *code, GError **error) +{ return find_one(dao, TYPE_COLUMNS "code=?;", FALSE, 0, code, error); } +RelationType *relation_type_dao_find_by_normalized_key(RelationTypeDao *dao, + const char *key, GError **error) +{ return find_one(dao, TYPE_COLUMNS "normalized_key=?;", FALSE, 0, key, error); } + +gboolean relation_type_dao_insert_custom(RelationTypeDao *dao, + const char *label, const char *key, const char *description, + gint64 *out_identifier, GError **error) +{ + const char *sql = "INSERT INTO relation_types(code,label,normalized_key," + "description,is_system) VALUES(NULL,?,?,?,0);"; + DatabaseStatement *s = database_statement_prepare(dao->database, sql); + if (s == NULL) { set_db_error(dao,error,RELATION_TYPE_DAO_ERROR_PREPARE, + "Création du type impossible"); return FALSE; } + if (!database_statement_bind_text(s,1,label) || + !database_statement_bind_text(s,2,key) || + (description != NULL ? !database_statement_bind_text(s,3,description) + : !database_statement_bind_null(s,3)) || + database_statement_step(s) != DATABASE_STATEMENT_STEP_DONE) + { set_db_error(dao,error,RELATION_TYPE_DAO_ERROR_CONSTRAINT, + "Ce libellé de type existe déjà"); database_statement_finalize(s); return FALSE; } + database_statement_finalize(s); + if (out_identifier != NULL) + { + int64_t identifier = 0; + s = database_statement_prepare(dao->database, + "SELECT last_insert_rowid();"); + if (s == NULL || + database_statement_step(s) != DATABASE_STATEMENT_STEP_ROW || + !database_statement_column_int64(s, 0, &identifier)) + { + set_db_error(dao, error, RELATION_TYPE_DAO_ERROR_READ, + "Identifiant du type indisponible"); + database_statement_finalize(s); + return FALSE; + } + *out_identifier = (gint64) identifier; + database_statement_finalize(s); + } + return TRUE; +} + +gboolean relation_type_dao_update_label(RelationTypeDao *dao, gint64 id, + const char *label, const char *key, GError **error) +{ + DatabaseStatement *s = database_statement_prepare(dao->database, + "UPDATE relation_types SET label=?,normalized_key=? WHERE id=?;"); + if (s == NULL || !database_statement_bind_text(s,1,label) || + !database_statement_bind_text(s,2,key) || + !database_statement_bind_int64(s,3,id) || + database_statement_step(s) != DATABASE_STATEMENT_STEP_DONE) + { set_db_error(dao,error,RELATION_TYPE_DAO_ERROR_CONSTRAINT, + "Renommage du type impossible"); database_statement_finalize(s); return FALSE; } + database_statement_finalize(s); return TRUE; +} + +gboolean relation_type_dao_count_relations(RelationTypeDao *dao, gint64 id, + guint64 *count, GError **error) +{ + DatabaseStatement *s = database_statement_prepare(dao->database, + "SELECT COUNT(*) FROM relations WHERE relation_type_id=?;"); + int64_t value = 0; + if (s == NULL || !database_statement_bind_int64(s,1,id) || + database_statement_step(s) != DATABASE_STATEMENT_STEP_ROW || + !database_statement_column_int64(s,0,&value)) + { set_db_error(dao,error,RELATION_TYPE_DAO_ERROR_EXECUTE, + "Comptage des relations impossible"); database_statement_finalize(s); return FALSE; } + *count = (guint64) value; database_statement_finalize(s); return TRUE; +} + +gboolean relation_type_dao_delete_unused(RelationTypeDao *dao, gint64 id, + GError **error) +{ + DatabaseStatement *s = database_statement_prepare(dao->database, + "DELETE FROM relation_types WHERE id=? AND is_system=0 AND NOT EXISTS" + "(SELECT 1 FROM relations WHERE relation_type_id=?);"); + if (s == NULL || !database_statement_bind_int64(s,1,id) || + !database_statement_bind_int64(s,2,id) || + database_statement_step(s) != DATABASE_STATEMENT_STEP_DONE) + { set_db_error(dao,error,RELATION_TYPE_DAO_ERROR_CONSTRAINT, + "Suppression du type refusée"); database_statement_finalize(s); return FALSE; } + database_statement_finalize(s); return TRUE; +} diff --git a/src/database/database.c b/src/database/database.c index 1b7fa0e..26c9055 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 8 +#define DATABASE_SCHEMA_VERSION_CURRENT 9 /** * @brief Version actuelle sous forme textuelle pour metadata. */ -#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "8" +#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "9" /** * @brief Nom de l'application enregistré dans les métadonnées. @@ -782,6 +782,21 @@ rollback: return false; } +static bool database_migrate_v8_to_v9(Database *database) +{ + bool transaction_started = false; + if (database == NULL || !database_transaction_begin(database)) return false; + transaction_started = true; + if (!schema_install_v9(database) || + !database_update_schema_version(database, "9") || + !database_transaction_commit(database)) goto rollback; + return true; +rollback: + if (transaction_started && !database_transaction_rollback(database)) + g_warning("Impossible d’annuler la migration SQLite V8 vers V9."); + return false; +} + /** * @brief Garantit atomiquement la présence des extensions du schéma courant. */ @@ -1003,6 +1018,10 @@ bool database_migrate_to_latest( return false; schema_version = 8; break; + case 8: + if (!database_migrate_v8_to_v9(database)) return false; + schema_version = 9; + break; default: database_set_error( @@ -1135,6 +1154,13 @@ bool database_initialize( goto rollback; } + if (!schema_install_v7(database) || + !schema_install_v8(database) || + !schema_install_v9(database)) + { + goto rollback; + } + if (!schema_ensure_current( database )) diff --git a/src/database/schema.c b/src/database/schema.c index fa4df0b..ec6018f 100644 --- a/src/database/schema.c +++ b/src/database/schema.c @@ -6,6 +6,7 @@ #include "database/schema.h" #include "database_internal.h" +#include "core/relation_type_normalizer.h" #include #include @@ -239,6 +240,113 @@ bool schema_install_v8(Database *database) "la migration SQLite V8"); } +static gboolean schema_v9_column_exists(sqlite3 *handle) +{ + sqlite3_stmt *statement = NULL; + gboolean found = FALSE; + if (sqlite3_prepare_v2(handle, "PRAGMA table_info(relations);", -1, + &statement, NULL) != SQLITE_OK) return FALSE; + while (sqlite3_step(statement) == SQLITE_ROW) + if (g_strcmp0((const char *) sqlite3_column_text(statement, 1), + "relation_type_id") == 0) found = TRUE; + sqlite3_finalize(statement); + return found; +} + +static gboolean schema_v9_migrate_relation_types(Database *database) +{ + sqlite3 *handle = database_get_handle(database); + sqlite3_stmt *read_statement = NULL; + sqlite3_stmt *find_statement = NULL; + sqlite3_stmt *insert_statement = NULL; + sqlite3_stmt *update_statement = NULL; + gboolean success = FALSE; + + if (!schema_v9_column_exists(handle) && + sqlite3_exec(handle, "ALTER TABLE relations ADD COLUMN " + "relation_type_id INTEGER REFERENCES relation_types(id) " + "ON UPDATE CASCADE ON DELETE RESTRICT;", NULL, NULL, NULL) != SQLITE_OK) + return FALSE; + if (sqlite3_prepare_v2(handle, "SELECT id,type_relation FROM relations " + "WHERE relation_type_id IS NULL ORDER BY created_at,id;", -1, + &read_statement, NULL) != SQLITE_OK || + sqlite3_prepare_v2(handle, "SELECT id FROM relation_types WHERE " + "code=?1 OR normalized_key=?2 ORDER BY code IS NULL LIMIT 1;", -1, + &find_statement, NULL) != SQLITE_OK || + sqlite3_prepare_v2(handle, "INSERT INTO relation_types(code,label," + "normalized_key,description,is_system) VALUES(NULL,?1,?2,NULL,0);", + -1, &insert_statement, NULL) != SQLITE_OK || + sqlite3_prepare_v2(handle, "UPDATE relations SET relation_type_id=?1 " + "WHERE id=?2;", -1, &update_statement, NULL) != SQLITE_OK) + goto cleanup; + + while (sqlite3_step(read_statement) == SQLITE_ROW) + { + const char *relation_id = + (const char *) sqlite3_column_text(read_statement, 0); + const char *label = + (const char *) sqlite3_column_text(read_statement, 1); + char *key = relation_type_normalize_key(label); + sqlite3_int64 type_id = 0; + if (key == NULL) goto cleanup; + sqlite3_bind_text(find_statement, 1, label, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(find_statement, 2, key, -1, SQLITE_TRANSIENT); + if (sqlite3_step(find_statement) == SQLITE_ROW) + type_id = sqlite3_column_int64(find_statement, 0); + sqlite3_reset(find_statement); sqlite3_clear_bindings(find_statement); + if (type_id == 0) + { + sqlite3_bind_text(insert_statement, 1, label, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(insert_statement, 2, key, -1, SQLITE_TRANSIENT); + if (sqlite3_step(insert_statement) != SQLITE_DONE) + { g_free(key); goto cleanup; } + type_id = sqlite3_last_insert_rowid(handle); + sqlite3_reset(insert_statement); + sqlite3_clear_bindings(insert_statement); + } + sqlite3_bind_int64(update_statement, 1, type_id); + sqlite3_bind_text(update_statement, 2, relation_id, -1, SQLITE_TRANSIENT); + if (sqlite3_step(update_statement) != SQLITE_DONE) + { g_free(key); goto cleanup; } + sqlite3_reset(update_statement); sqlite3_clear_bindings(update_statement); + g_free(key); + } + if (sqlite3_exec(handle, + "CREATE UNIQUE INDEX IF NOT EXISTS idx_relations_canonical_type " + "ON relations(entite_source_id,entite_cible_id,relation_type_id);" + "CREATE INDEX IF NOT EXISTS idx_relations_relation_type_id " + "ON relations(relation_type_id);" + "CREATE TRIGGER IF NOT EXISTS relations_require_canonical_type_insert " + "BEFORE INSERT ON relations WHEN NEW.relation_type_id IS NULL " + "BEGIN SELECT RAISE(ABORT,'relation_type_id is required'); END;" + "CREATE TRIGGER IF NOT EXISTS relations_require_canonical_type_update " + "BEFORE UPDATE OF relation_type_id ON relations " + "WHEN NEW.relation_type_id IS NULL " + "BEGIN SELECT RAISE(ABORT,'relation_type_id is required'); END;", + NULL, NULL, NULL) != SQLITE_OK) + goto cleanup; + { + sqlite3_stmt *check = NULL; + if (sqlite3_prepare_v2(handle, "PRAGMA foreign_key_check;", -1, + &check, NULL) != SQLITE_OK) goto cleanup; + success = sqlite3_step(check) == SQLITE_DONE; + sqlite3_finalize(check); + } +cleanup: + sqlite3_finalize(update_statement); sqlite3_finalize(insert_statement); + sqlite3_finalize(find_statement); sqlite3_finalize(read_statement); + if (!success) database_set_error(database, DATABASE_ERROR_SQLITE, + sqlite3_errmsg(handle)); + return success; +} + +bool schema_install_v9(Database *database) +{ + return schema_execute_file(database, "database/schema_v9.sql", + "la migration SQLite V9") && + schema_v9_migrate_relation_types(database); +} + bool schema_ensure_current( Database *database ) diff --git a/src/models/relation_record.c b/src/models/relation_record.c index 96bc728..4382620 100644 --- a/src/models/relation_record.c +++ b/src/models/relation_record.c @@ -16,6 +16,8 @@ struct RelationRecord char *source_entity_identifier; char *target_entity_identifier; char *relation_type; + gint64 relation_type_identifier; + char *relation_type_code; char *label; char *justification; @@ -511,6 +513,32 @@ cleanup: return relation_record; } +RelationRecord *relation_record_new_canonical( + const char *identifier, const char *source_entity_identifier, + const char *target_entity_identifier, gint64 relation_type_identifier, + const char *relation_type_code, const char *relation_type_label, + const char *label, const char *justification, gint confidence, + const char *created_at, const char *updated_at, RelationStatus status, + GError **error) +{ + RelationRecord *record = NULL; + if (relation_type_identifier <= 0) + { + g_set_error_literal(error, RELATION_RECORD_ERROR, + RELATION_RECORD_ERROR_INVALID_TYPE, + "L'identifiant du type canonique est invalide."); + return NULL; + } + record = relation_record_new(identifier, source_entity_identifier, + target_entity_identifier, relation_type_label, label, justification, + confidence, created_at, updated_at, status, error); + if (record == NULL) return NULL; + record->relation_type_identifier = relation_type_identifier; + record->relation_type_code = relation_record_duplicate_optional( + relation_type_code); + return record; +} + void relation_record_free( RelationRecord *relation_record ) @@ -539,6 +567,7 @@ void relation_record_free( g_free( relation_record->relation_type ); + g_free(relation_record->relation_type_code); g_free( relation_record->target_entity_identifier @@ -589,10 +618,30 @@ const char *relation_record_get_relation_type( ) { return relation_record != NULL - ? relation_record->relation_type + ? (relation_record->relation_type_code != NULL + ? relation_record->relation_type_code + : relation_record->relation_type) : NULL; } +gint64 relation_record_get_relation_type_identifier( + const RelationRecord *relation_record) +{ + return relation_record != NULL ? relation_record->relation_type_identifier : 0; +} + +const char *relation_record_get_relation_type_code( + const RelationRecord *relation_record) +{ + return relation_record != NULL ? relation_record->relation_type_code : NULL; +} + +const char *relation_record_get_relation_type_label( + const RelationRecord *relation_record) +{ + return relation_record != NULL ? relation_record->relation_type : NULL; +} + const char *relation_record_get_label( const RelationRecord *relation_record ) diff --git a/src/models/relation_type.c b/src/models/relation_type.c new file mode 100644 index 0000000..d1f0a6d --- /dev/null +++ b/src/models/relation_type.c @@ -0,0 +1,89 @@ +#include "models/relation_type.h" +#include "core/relation_type_normalizer.h" + +struct RelationType +{ + gint64 identifier; + char *code; + char *label; + char *normalized_key; + char *description; + gboolean system_type; +}; + +static char *relation_type_optional(const char *value) +{ + char *copy = value != NULL ? g_strdup(value) : NULL; + if (copy != NULL) g_strstrip(copy); + if (copy != NULL && copy[0] == '\0') g_clear_pointer(©, g_free); + return copy; +} + +GQuark relation_type_error_quark(void) +{ + return g_quark_from_static_string("relation-type-error-quark"); +} + +RelationType *relation_type_new(gint64 identifier, const char *code, + const char *label, const char *normalized_key, const char *description, + gboolean system_type, GError **error) +{ + RelationType *type = NULL; + char *label_copy = NULL; + char *expected_key = NULL; + g_return_val_if_fail(error == NULL || *error == NULL, NULL); + if (identifier <= 0) + { + g_set_error_literal(error, RELATION_TYPE_ERROR, + RELATION_TYPE_ERROR_INVALID_IDENTIFIER, + "L'identifiant du type de relation est invalide."); + return NULL; + } + label_copy = relation_type_optional(label); + expected_key = relation_type_normalize_key(label_copy); + if (label_copy == NULL || expected_key == NULL) + { + g_set_error_literal(error, RELATION_TYPE_ERROR, + RELATION_TYPE_ERROR_INVALID_LABEL, + "Le libellé du type de relation est obligatoire."); + goto cleanup; + } + if (normalized_key == NULL || g_strcmp0(expected_key, normalized_key) != 0) + { + g_set_error_literal(error, RELATION_TYPE_ERROR, + RELATION_TYPE_ERROR_INVALID_NORMALIZED_KEY, + "La clé normalisée du type de relation est incohérente."); + goto cleanup; + } + type = g_new0(RelationType, 1); + type->identifier = identifier; + type->code = relation_type_optional(code); + type->label = label_copy; + type->normalized_key = g_strdup(normalized_key); + type->description = relation_type_optional(description); + type->system_type = system_type; + label_copy = NULL; +cleanup: + g_free(expected_key); + g_free(label_copy); + return type; +} + +void relation_type_free(RelationType *type) +{ + if (type == NULL) return; + g_free(type->description); g_free(type->normalized_key); + g_free(type->label); g_free(type->code); g_free(type); +} +gint64 relation_type_get_identifier(const RelationType *type) +{ return type != NULL ? type->identifier : 0; } +const char *relation_type_get_code(const RelationType *type) +{ return type != NULL ? type->code : NULL; } +const char *relation_type_get_label(const RelationType *type) +{ return type != NULL ? type->label : NULL; } +const char *relation_type_get_normalized_key(const RelationType *type) +{ return type != NULL ? type->normalized_key : NULL; } +const char *relation_type_get_description(const RelationType *type) +{ return type != NULL ? type->description : NULL; } +gboolean relation_type_is_system(const RelationType *type) +{ return type != NULL && type->system_type; } diff --git a/src/views/create_relation_dialog.c b/src/views/create_relation_dialog.c index 64188bb..498124a 100644 --- a/src/views/create_relation_dialog.c +++ b/src/views/create_relation_dialog.c @@ -8,6 +8,8 @@ #include "models/entity_record.h" #include "models/evidence_record.h" #include "models/relation_record.h" +#include "models/relation_type.h" +#include "core/relation_type_normalizer.h" #include @@ -20,6 +22,8 @@ struct CreateRelationDialogResult char *source_entity_identifier; char *target_entity_identifier; char *relation_type; + char *relation_type_code; + gint64 relation_type_identifier; char *label; char *justification; GPtrArray *evidence_identifiers; @@ -56,7 +60,10 @@ typedef struct GtkWindow *window; GtkDropDown *target_drop_down; - GtkEntry *relation_type_entry; + GtkDropDown *relation_type_drop_down; + GtkStringList *relation_type_string_list; + GtkEntry *new_relation_type_entry; + GtkWidget *relation_type_control; GtkEntry *label_entry; GtkTextView *justification_text_view; GtkSpinButton *confidence_spin_button; @@ -64,6 +71,9 @@ typedef struct char *source_entity_identifier; GPtrArray *target_identifiers; + GArray *relation_type_identifiers; + GPtrArray *relation_type_codes; + GPtrArray *relation_type_labels; GPtrArray *evidence_options; GPtrArray *evidence_check_buttons; GtkWidget *evidence_preview_stack; @@ -78,6 +88,51 @@ typedef struct gboolean completed; } CreateRelationDialogState; +static void create_relation_dialog_show_error( + CreateRelationDialogState *state, const char *message); + +static void create_relation_dialog_on_new_type_clicked( + GtkButton *button, gpointer user_data) +{ + CreateRelationDialogState *state = user_data; + const char *label = NULL; + char *key = NULL; + (void) button; + if (state == NULL) return; + label = gtk_editable_get_text(GTK_EDITABLE(state->new_relation_type_entry)); + key = relation_type_normalize_key(label); + if (key == NULL) + { + create_relation_dialog_show_error(state, + "Saisissez le libellé du nouveau type."); + return; + } + for (guint index = 0U; index < state->relation_type_labels->len; index++) + { + char *existing_key = relation_type_normalize_key( + g_ptr_array_index(state->relation_type_labels, index)); + gboolean duplicate = g_strcmp0(key, existing_key) == 0; + g_free(existing_key); + if (duplicate) + { + create_relation_dialog_show_error(state, + "Un type de relation équivalent existe déjà."); + gtk_drop_down_set_selected(state->relation_type_drop_down, index); + g_free(key); + return; + } + } + gint64 identifier = 0; + gtk_string_list_append(state->relation_type_string_list, label); + g_array_append_val(state->relation_type_identifiers, identifier); + g_ptr_array_add(state->relation_type_codes, NULL); + g_ptr_array_add(state->relation_type_labels, g_strdup(label)); + gtk_drop_down_set_selected(state->relation_type_drop_down, + state->relation_type_labels->len - 1U); + gtk_editable_set_text(GTK_EDITABLE(state->new_relation_type_entry), ""); + g_free(key); +} + /** * @brief Retourne une copie nettoyée ou NULL pour une valeur vide. */ @@ -381,6 +436,10 @@ static void create_relation_dialog_state_free( &state->target_identifiers, g_ptr_array_unref ); + g_clear_pointer(&state->relation_type_identifiers, g_array_unref); + g_clear_pointer(&state->relation_type_codes, g_ptr_array_unref); + g_clear_pointer(&state->relation_type_labels, g_ptr_array_unref); + g_clear_object(&state->relation_type_string_list); create_relation_dialog_clear_preview(state); g_clear_pointer(&state->evidence_options, g_ptr_array_unref); g_clear_pointer(&state->evidence_check_buttons, g_ptr_array_unref); @@ -533,12 +592,17 @@ static void create_relation_dialog_on_create_clicked( selected_index ); - relation_type = - gtk_editable_get_text( - GTK_EDITABLE( - state->relation_type_entry - ) - ); + guint relation_type_index = + gtk_drop_down_get_selected(state->relation_type_drop_down); + if (relation_type_index == GTK_INVALID_LIST_POSITION || + relation_type_index >= state->relation_type_labels->len) + { + create_relation_dialog_show_error( + state, "Sélectionnez un type de relation canonique."); + return; + } + relation_type = g_ptr_array_index( + state->relation_type_labels, relation_type_index); label = gtk_editable_get_text( @@ -617,6 +681,10 @@ static void create_relation_dialog_on_create_clicked( create_relation_dialog_duplicate_required_text( relation_type ); + result->relation_type_identifier = g_array_index( + state->relation_type_identifiers, gint64, relation_type_index); + result->relation_type_code = g_strdup(g_ptr_array_index( + state->relation_type_codes, relation_type_index)); result->label = create_relation_dialog_duplicate_optional_text( @@ -810,6 +878,7 @@ static gboolean create_relation_dialog_present_internal( const char *source_entity_identifier, const RelationRecord *existing_relation, const GPtrArray *entities, + const GPtrArray *relation_types, const GPtrArray *evidence_records, const GPtrArray *selected_evidence_identifiers, const char *investigation_root_path, @@ -823,6 +892,7 @@ static gboolean create_relation_dialog_present_internal( GtkStringList *target_labels = NULL; + GtkStringList *relation_type_labels = NULL; GtkWidget *root_box = NULL; @@ -1381,15 +1451,48 @@ static gboolean create_relation_dialog_present_internal( TRUE ); - state->relation_type_entry = - GTK_ENTRY( - gtk_entry_new() - ); - - gtk_entry_set_placeholder_text( - state->relation_type_entry, - "Ex. : utilise, contrôle, appartient à" - ); + relation_type_labels = gtk_string_list_new(NULL); + state->relation_type_string_list = g_object_ref(relation_type_labels); + state->relation_type_identifiers = + g_array_new(FALSE, FALSE, sizeof(gint64)); + state->relation_type_codes = g_ptr_array_new_with_free_func(g_free); + state->relation_type_labels = g_ptr_array_new_with_free_func(g_free); + guint selected_relation_type_index = 0U; + for (guint type_index = 0U; + relation_types != NULL && type_index < relation_types->len; + type_index++) + { + const RelationType *type = g_ptr_array_index(relation_types, type_index); + gint64 type_identifier = relation_type_get_identifier(type); + gtk_string_list_append(relation_type_labels, + relation_type_get_label(type)); + g_array_append_val(state->relation_type_identifiers, type_identifier); + g_ptr_array_add(state->relation_type_codes, + g_strdup(relation_type_get_code(type))); + g_ptr_array_add(state->relation_type_labels, + g_strdup(relation_type_get_label(type))); + if (existing_relation != NULL && + type_identifier == + relation_record_get_relation_type_identifier(existing_relation)) + selected_relation_type_index = type_index; + } + state->relation_type_drop_down = GTK_DROP_DOWN( + gtk_drop_down_new(G_LIST_MODEL(relation_type_labels), NULL)); + gtk_drop_down_set_selected(state->relation_type_drop_down, + selected_relation_type_index); + state->new_relation_type_entry = GTK_ENTRY(gtk_entry_new()); + gtk_entry_set_placeholder_text(state->new_relation_type_entry, + "Nouveau type…"); + GtkWidget *new_type_button = gtk_button_new_with_label("Ajouter"); + state->relation_type_control = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_set_hexpand(GTK_WIDGET(state->relation_type_drop_down), TRUE); + gtk_box_append(GTK_BOX(state->relation_type_control), + GTK_WIDGET(state->relation_type_drop_down)); + gtk_box_append(GTK_BOX(state->relation_type_control), + GTK_WIDGET(state->new_relation_type_entry)); + gtk_box_append(GTK_BOX(state->relation_type_control), new_type_button); + g_signal_connect(new_type_button, "clicked", + G_CALLBACK(create_relation_dialog_on_new_type_clicked), state); state->label_entry = GTK_ENTRY( @@ -1424,13 +1527,9 @@ static gboolean create_relation_dialog_present_internal( if (existing_relation != NULL) { - const char *relation_type = relation_record_get_relation_type( - existing_relation); const char *label = relation_record_get_label(existing_relation); const char *justification = relation_record_get_justification( existing_relation); - gtk_editable_set_text(GTK_EDITABLE(state->relation_type_entry), - relation_type != NULL ? relation_type : ""); gtk_editable_set_text(GTK_EDITABLE(state->label_entry), label != NULL ? label : ""); gtk_text_buffer_set_text(gtk_text_view_get_buffer( @@ -1568,7 +1667,7 @@ static gboolean create_relation_dialog_present_internal( grid ), GTK_WIDGET( - state->relation_type_entry + state->relation_type_control ), 1, 1, @@ -1809,7 +1908,7 @@ static gboolean create_relation_dialog_present_internal( gtk_widget_grab_focus( GTK_WIDGET( - state->relation_type_entry + state->relation_type_drop_down ) ); @@ -1822,16 +1921,19 @@ static gboolean create_relation_dialog_present_internal( gboolean create_relation_dialog_present(GtkWindow *parent, const char *source_entity_identifier, const GPtrArray *entities, + const GPtrArray *relation_types, const GPtrArray *evidence_records, const char *investigation_root_path, CreateRelationDialogCallback callback, gpointer user_data, GError **error) { return create_relation_dialog_present_internal(parent, - source_entity_identifier, NULL, entities, evidence_records, NULL, + source_entity_identifier, NULL, entities, relation_types, + evidence_records, NULL, investigation_root_path, callback, user_data, error); } gboolean edit_relation_dialog_present(GtkWindow *parent, const RelationRecord *relation_record, const GPtrArray *entities, + const GPtrArray *relation_types, const GPtrArray *evidence_records, const GPtrArray *selected_evidence_identifiers, const char *investigation_root_path, @@ -1846,7 +1948,7 @@ gboolean edit_relation_dialog_present(GtkWindow *parent, } return create_relation_dialog_present_internal(parent, relation_record_get_source_entity_identifier(relation_record), - relation_record, entities, evidence_records, + relation_record, entities, relation_types, evidence_records, selected_evidence_identifiers, investigation_root_path, callback, user_data, error); } @@ -1867,6 +1969,7 @@ void create_relation_dialog_result_free( g_free( result->label ); + g_free(result->relation_type_code); g_free( result->relation_type @@ -1920,6 +2023,18 @@ const char *create_relation_dialog_result_get_relation_type( : NULL; } +gint64 create_relation_dialog_result_get_relation_type_identifier( + const CreateRelationDialogResult *result) +{ + return result != NULL ? result->relation_type_identifier : 0; +} + +const char *create_relation_dialog_result_get_relation_type_code( + const CreateRelationDialogResult *result) +{ + return result != NULL ? result->relation_type_code : NULL; +} + const char *create_relation_dialog_result_get_label( const CreateRelationDialogResult *result ) diff --git a/src/widgets/investigation_graph_view.c b/src/widgets/investigation_graph_view.c index 6522ebd..beb4b34 100644 --- a/src/widgets/investigation_graph_view.c +++ b/src/widgets/investigation_graph_view.c @@ -3551,7 +3551,7 @@ static const char *investigation_graph_view_get_relation_title( } relation_type = - relation_record_get_relation_type( + relation_record_get_relation_type_label( relation_record ); diff --git a/src/widgets/sidebar.c b/src/widgets/sidebar.c index d911ca0..03e7622 100644 --- a/src/widgets/sidebar.c +++ b/src/widgets/sidebar.c @@ -184,7 +184,7 @@ static gboolean sidebar_filter_relation_row( return sidebar_text_contains_search( relation_record_get_label(relation_record), search_text) || sidebar_text_contains_search( - relation_record_get_relation_type(relation_record), search_text) || + relation_record_get_relation_type_label(relation_record), search_text) || sidebar_text_contains_search( entity_record_get_value(source_entity), search_text) || sidebar_text_contains_search( @@ -1450,7 +1450,7 @@ void sidebar_set_graph_model( GtkWidget *title_label = gtk_label_new( label != NULL && label[0] != '\0' ? label - : relation_record_get_relation_type(relation_record) + : relation_record_get_relation_type_label(relation_record) ); GtkWidget *details_label = gtk_label_new(details); diff --git a/src/widgets/workspace.c b/src/widgets/workspace.c index 35f0c92..85f0714 100644 --- a/src/widgets/workspace.c +++ b/src/widgets/workspace.c @@ -14,6 +14,7 @@ #include "widgets/investigation_graph_view.h" #include +#include #define WORKSPACE_PAGE_WELCOME "welcome" #define WORKSPACE_PAGE_NODE_INFORMATION "node-information" @@ -698,6 +699,26 @@ static void workspace_on_expand_evidence_preview(GtkButton *button, content = gtk_video_new_for_filename(workspace->evidence_preview_path); gtk_video_set_autoplay(GTK_VIDEO(content), FALSE); } + else if (gtk_stack_get_visible_child(GTK_STACK( + workspace->evidence_preview_stack)) == + workspace->evidence_preview_status) + { + GFile *file = g_file_new_for_path(workspace->evidence_preview_path); + char *uri = g_file_get_uri(file); + GError *error = NULL; + if (uri == NULL || !g_app_info_launch_default_for_uri(uri, NULL, + &error)) + { + gtk_label_set_text(GTK_LABEL(workspace->evidence_preview_status), + error != NULL ? error->message : + "Impossible d’ouvrir ce document."); + g_clear_error(&error); + } + g_free(uri); + g_object_unref(file); + gtk_window_destroy(window); + return; + } else { content = gtk_picture_new_for_filename(workspace->evidence_preview_path); @@ -708,6 +729,20 @@ static void workspace_on_expand_evidence_preview(GtkButton *button, gtk_window_present(window); } +/** @brief Ouvre l’aperçu agrandi lorsqu’un utilisateur clique dessus. */ +static void workspace_on_evidence_preview_clicked(GtkGestureClick *gesture, + gint n_press, gdouble x, gdouble y, gpointer user_data) +{ + Workspace *workspace = user_data; + (void) gesture; + (void) x; + (void) y; + if (workspace == NULL || n_press != 1 || + workspace->evidence_preview_path == NULL) + return; + workspace_on_expand_evidence_preview(NULL, workspace); +} + /** @brief Convertit le statut d'une relation en texte utilisateur. */ static const char *workspace_get_relation_status_text(RelationStatus status) { @@ -842,7 +877,7 @@ static void workspace_on_graph_node_selected( "Source inconnue", target_entity != NULL ? entity_record_get_value(target_entity) : "Cible inconnue", - relation_record_get_relation_type(relation_record), + relation_record_get_relation_type_label(relation_record), relation_record_get_confidence(relation_record), workspace_get_relation_status_text( relation_record_get_status(relation_record)), @@ -1370,7 +1405,7 @@ Workspace *workspace_new(void) evidence_content = gtk_box_new( GTK_ORIENTATION_VERTICAL, - 16 + 8 ); if (evidence_content == NULL) @@ -1384,22 +1419,22 @@ Workspace *workspace_new(void) gtk_widget_set_margin_start( evidence_content, - 24 + 12 ); gtk_widget_set_margin_end( evidence_content, - 24 + 12 ); gtk_widget_set_margin_top( evidence_content, - 24 + 12 ); gtk_widget_set_margin_bottom( evidence_content, - 24 + 12 ); workspace->evidence_name_label = @@ -1454,8 +1489,13 @@ Workspace *workspace_new(void) workspace->evidence_preview_video = gtk_video_new(); workspace->evidence_preview_expand_button = gtk_button_new_with_label("Agrandir l’aperçu"); - gtk_widget_set_size_request(workspace->evidence_preview_stack, -1, 360); + gtk_widget_set_size_request(workspace->evidence_preview_stack, -1, 180); + gtk_widget_set_vexpand(workspace->evidence_preview_stack, FALSE); gtk_widget_set_hexpand(workspace->evidence_preview_stack, TRUE); + gtk_widget_set_size_request(workspace->evidence_preview_picture, -1, 180); + gtk_widget_set_size_request(workspace->evidence_preview_video, -1, 180); + gtk_widget_set_vexpand(workspace->evidence_preview_picture, FALSE); + gtk_widget_set_vexpand(workspace->evidence_preview_video, FALSE); gtk_picture_set_can_shrink(GTK_PICTURE( workspace->evidence_preview_picture), TRUE); gtk_picture_set_content_fit(GTK_PICTURE( @@ -1473,6 +1513,23 @@ Workspace *workspace_new(void) workspace->evidence_preview_picture, "image"); gtk_stack_add_named(GTK_STACK(workspace->evidence_preview_stack), workspace->evidence_preview_video, "video"); + { + GtkGesture *gesture = gtk_gesture_click_new(); + gtk_widget_add_controller(workspace->evidence_preview_picture, + GTK_EVENT_CONTROLLER(gesture)); + g_signal_connect(gesture, "released", + G_CALLBACK(workspace_on_evidence_preview_clicked), workspace); + gesture = gtk_gesture_click_new(); + gtk_widget_add_controller(workspace->evidence_preview_video, + GTK_EVENT_CONTROLLER(gesture)); + g_signal_connect(gesture, "released", + G_CALLBACK(workspace_on_evidence_preview_clicked), workspace); + gesture = gtk_gesture_click_new(); + gtk_widget_add_controller(workspace->evidence_preview_text, + GTK_EVENT_CONTROLLER(gesture)); + g_signal_connect(gesture, "released", + G_CALLBACK(workspace_on_evidence_preview_clicked), workspace); + } gtk_stack_set_visible_child_name(GTK_STACK( workspace->evidence_preview_stack), "status"); gtk_widget_set_halign(workspace->evidence_preview_expand_button, @@ -2962,7 +3019,8 @@ void workspace_set_evidence_preview(Workspace *workspace, g_free(contents); } message = g_strdup_printf( - "Aperçu indisponible pour %s%s%s.", + "Aperçu intégré indisponible pour %s%s%s. Utilisez le bouton " + "pour ouvrir le document avec l’application associée.", display_name != NULL ? display_name : "ce fichier", error != NULL ? " : " : "", error != NULL ? error->message : "format non pris en charge"); @@ -2970,6 +3028,12 @@ void workspace_set_evidence_preview(Workspace *workspace, message); gtk_stack_set_visible_child_name(GTK_STACK( workspace->evidence_preview_stack), "status"); + if (g_file_test(file_path, G_FILE_TEST_IS_REGULAR)) + { + workspace->evidence_preview_path = g_strdup(file_path); + gtk_widget_set_sensitive(workspace->evidence_preview_expand_button, + workspace->evidence_preview_path != NULL); + } g_free(message); goto cleanup; } diff --git a/tests/test_database.c b/tests/test_database.c index d61e342..a2df087 100644 --- a/tests/test_database.c +++ b/tests/test_database.c @@ -525,7 +525,8 @@ static void test_database_initialize_valid_database(void) "FROM investigation;" ); - assert(strcmp(schema_version, "8") == 0); + assert(strcmp(schema_version, "9") == 0); + test_database_assert_table_exists(database, "relation_types"); test_database_assert_table_exists(database, "graph_viewport"); test_database_assert_table_exists(database, "osint_executions"); test_database_assert_table_exists(database, "osint_execution_entities"); @@ -990,7 +991,7 @@ static void test_database_migrate_v1_to_v2(void) assert( strcmp( schema_version, - "8" + "9" ) == 0 ); diff --git a/tests/test_investigation_graph_loader.c b/tests/test_investigation_graph_loader.c index e66a0c8..aeab424 100644 --- a/tests/test_investigation_graph_loader.c +++ b/tests/test_investigation_graph_loader.c @@ -491,6 +491,7 @@ static void test_graph_loader_insert_inconsistent_relation( " entite_source_id," " entite_cible_id," " type_relation," + " relation_type_id," " confiance," " created_at," " updated_at," @@ -502,6 +503,7 @@ static void test_graph_loader_insert_inconsistent_relation( " ?," " ?," " ?," + " (SELECT id FROM relation_types WHERE code='uses')," " ?," " ?," " ?," diff --git a/tests/test_relation_dao.c b/tests/test_relation_dao.c index 2b7d2d7..b692c79 100644 --- a/tests/test_relation_dao.c +++ b/tests/test_relation_dao.c @@ -1990,6 +1990,7 @@ static void test_relation_dao_reflexive_schema_constraint(void) " entite_source_id," " entite_cible_id," " type_relation," + " relation_type_id," " confiance," " created_at," " updated_at," @@ -2001,6 +2002,7 @@ static void test_relation_dao_reflexive_schema_constraint(void) " ?," " ?," " ?," + " (SELECT id FROM relation_types WHERE code='uses')," " ?," " ?," " ?," diff --git a/tests/test_relation_type_normalizer.c b/tests/test_relation_type_normalizer.c new file mode 100644 index 0000000..aeca360 --- /dev/null +++ b/tests/test_relation_type_normalizer.c @@ -0,0 +1,37 @@ +#include "core/relation_type_normalizer.h" +#include + +static void assert_same(const char *left, const char *right) +{ + char *left_key = relation_type_normalize_key(left); + char *right_key = relation_type_normalize_key(right); + g_assert_cmpstr(left_key, ==, right_key); + g_free(right_key); g_free(left_key); +} + +static void test_spaces(void) +{ + assert_same("email", " Email "); + assert_same("Réseaux sociaux", "Réseaux Sociaux"); + assert_same("Réseaux sociaux", "Réseaux\t\nSociaux"); +} +static void test_unicode(void) +{ + assert_same("RéSEAUX", "re\xCC\x81seaux"); + assert_same("STRASSE", "Straße"); +} +static void test_distinct_meanings(void) +{ + char *first = relation_type_normalize_key("Envoie fausses places"); + char *second = relation_type_normalize_key("Envoie des fausses places"); + g_assert_cmpstr(first, !=, second); + g_free(second); g_free(first); +} +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/relation-type-normalizer/spaces", test_spaces); + g_test_add_func("/relation-type-normalizer/unicode", test_unicode); + g_test_add_func("/relation-type-normalizer/distinct", test_distinct_meanings); + return g_test_run(); +} diff --git a/tests/test_relation_type_service.c b/tests/test_relation_type_service.c new file mode 100644 index 0000000..115a9c7 --- /dev/null +++ b/tests/test_relation_type_service.c @@ -0,0 +1,53 @@ +#include "core/relation_type_service.h" +#include "database/database.h" +#include +#include + +static void test_service_crud_and_merge(void) +{ + GError *error = NULL; + char *directory = g_dir_make_tmp("labfy-relation-types-XXXXXX", &error); + char *path = g_build_filename(directory, "Enquete.sqlite", NULL); + Database *database = NULL; + RelationTypeService *service = NULL; + gint64 email_id = 0; gint64 source_id = 0; gint64 target_id = 0; + guint64 count = 99; + + g_assert_no_error(error); + g_assert_true(database_initialize(path, "Types", directory)); + database = database_open(path); + g_assert_nonnull(database); + g_assert_true(database_migrate_to_latest(database)); + service = relation_type_service_new(database, &error); + g_assert_no_error(error); g_assert_nonnull(service); + g_assert_true(relation_type_service_create_custom( + service, " Email ", NULL, &email_id, &error)); + g_assert_cmpint(email_id, >, 0); + g_assert_false(relation_type_service_create_custom( + service, "EMAIL", NULL, NULL, &error)); + g_assert_error(error, RELATION_TYPE_SERVICE_ERROR, + RELATION_TYPE_SERVICE_ERROR_DUPLICATE); + g_clear_error(&error); + g_assert_true(relation_type_service_rename( + service, email_id, "Adresse e-mail", &error)); + g_assert_true(relation_type_service_count_usage( + service, email_id, &count, &error)); + g_assert_cmpuint(count, ==, 0U); + g_assert_true(relation_type_service_delete(service, email_id, &error)); + g_assert_true(relation_type_service_create_custom( + service, "Source temporaire", NULL, &source_id, &error)); + g_assert_true(relation_type_service_create_custom( + service, "Cible temporaire", NULL, &target_id, &error)); + g_assert_true(relation_type_service_merge( + service, source_id, target_id, &error)); + + relation_type_service_free(service); database_close(database); + g_remove(path); g_rmdir(directory); g_free(path); g_free(directory); +} +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/relation-type-service/crud-merge", + test_service_crud_and_merge); + return g_test_run(); +}