diff --git a/database/schema_current.sql b/database/schema_current.sql index 652f24d..e4796cd 100644 --- a/database/schema_current.sql +++ b/database/schema_current.sql @@ -1,7 +1,7 @@ /****************************************************************************** * Labfy Investigation * - * Extensions idempotentes du schéma SQLite courant V6 + * Extensions idempotentes du schéma SQLite courant V8 ******************************************************************************/ /* @@ -93,6 +93,15 @@ CREATE TABLE IF NOT EXISTS graph_layout_positions CHECK (length(updated_at) = 20) ); +CREATE TABLE IF NOT EXISTS graph_viewport +( + id INTEGER PRIMARY KEY CHECK (id = 1), + zoom REAL NOT NULL CHECK (zoom = zoom AND zoom > 0), + offset_x REAL NOT NULL CHECK (offset_x = offset_x), + offset_y REAL NOT NULL CHECK (offset_y = offset_y), + updated_at TEXT NOT NULL CHECK (length(updated_at) = 20) +); + /* 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_v8.sql b/database/schema_v8.sql new file mode 100644 index 0000000..97c942f --- /dev/null +++ b/database/schema_v8.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS graph_viewport +( + id INTEGER PRIMARY KEY CHECK (id = 1), + zoom REAL NOT NULL CHECK (zoom = zoom AND zoom > 0), + offset_x REAL NOT NULL CHECK (offset_x = offset_x), + offset_y REAL NOT NULL CHECK (offset_y = offset_y), + updated_at TEXT NOT NULL CHECK (length(updated_at) = 20) +); diff --git a/docs/database/DATABASE_ARCHITECTURE.md b/docs/database/DATABASE_ARCHITECTURE.md index 4dd778a..1913482 100644 --- a/docs/database/DATABASE_ARCHITECTURE.md +++ b/docs/database/DATABASE_ARCHITECTURE.md @@ -2269,6 +2269,12 @@ Cette stratégie préserve les positions existantes tout en permettant aux nœuds de relation de suivre exactement le même cycle de chargement, d'enregistrement et de réinitialisation que les nœuds d'entité. +Le cadrage global est stocké séparément dans la ligne unique de +`graph_viewport`. Elle conserve le niveau de zoom et les décalages horizontal +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. + --- # 12. Conclusion diff --git a/include/dao/graph_node_position_dao.h b/include/dao/graph_node_position_dao.h index e869144..bd1c4b8 100644 --- a/include/dao/graph_node_position_dao.h +++ b/include/dao/graph_node_position_dao.h @@ -143,6 +143,23 @@ gboolean graph_node_position_dao_delete_all( GError **error ); +gboolean graph_node_position_dao_load_viewport( + GraphNodePositionDao *position_dao, + double *zoom, + double *offset_x, + double *offset_y, + gboolean *found, + GError **error +); + +gboolean graph_node_position_dao_upsert_viewport( + GraphNodePositionDao *position_dao, + double zoom, + double offset_x, + double offset_y, + GError **error +); + G_END_DECLS #endif diff --git a/include/database/schema.h b/include/database/schema.h index 2ef2769..5219848 100644 --- a/include/database/schema.h +++ b/include/database/schema.h @@ -88,6 +88,7 @@ bool schema_install_v5(Database *database); bool schema_install_v6(Database *database); bool schema_install_v7(Database *database); +bool schema_install_v8(Database *database); /** * @brief Garantit la présence des extensions du schéma courant V2. diff --git a/include/views/main_window.h b/include/views/main_window.h index 870d742..8598c44 100644 --- a/include/views/main_window.h +++ b/include/views/main_window.h @@ -526,6 +526,11 @@ void main_window_set_graph( const InvestigationGraphLayout *graph_layout ); +gboolean main_window_get_graph_view_transform( + MainWindow *main_window, double *zoom, double *offset_x, double *offset_y); +void main_window_set_graph_view_transform( + MainWindow *main_window, double zoom, double offset_x, double offset_y); + /** @brief Sélectionne une relation dans le graphe affiché. */ gboolean main_window_select_graph_relation( MainWindow *main_window, diff --git a/include/widgets/workspace.h b/include/widgets/workspace.h index 02099e7..65c3810 100644 --- a/include/widgets/workspace.h +++ b/include/widgets/workspace.h @@ -447,6 +447,11 @@ void workspace_set_graph( const InvestigationGraphLayout *graph_layout ); +gboolean workspace_get_graph_view_transform( + Workspace *workspace, double *zoom, double *offset_x, double *offset_y); +void workspace_set_graph_view_transform( + Workspace *workspace, double zoom, double offset_x, double offset_y); + /** * @brief Affiche une erreur de chargement du graphe. * diff --git a/src/core/application.c b/src/core/application.c index 5e939c5..5e92949 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -137,6 +137,7 @@ struct Application InvestigationGraphModel *graph_model; InvestigationGraphLayout *graph_layout; guint64 graph_load_generation; + gboolean graph_viewport_restored; char *selected_evidence_identifier; char *pending_relation_selection_identifier; @@ -252,6 +253,59 @@ static void application_start_graph_loading( const char *database_path ); +static void application_save_graph_viewport(Application *application) +{ + GraphNodePositionDao *dao = NULL; + GError *error = NULL; + double zoom = 0.0; + double offset_x = 0.0; + double offset_y = 0.0; + + if (application == NULL || application->session == NULL || + application->main_window == NULL || + !main_window_get_graph_view_transform(application->main_window, + &zoom, &offset_x, &offset_y)) + return; + + dao = graph_node_position_dao_new( + investigation_session_get_database(application->session), &error); + if (dao == NULL || !graph_node_position_dao_upsert_viewport( + dao, zoom, offset_x, offset_y, &error)) + g_warning("Impossible d'enregistrer le cadrage du graphe : %s", + error != NULL ? error->message : "erreur inconnue"); + + graph_node_position_dao_free(dao); + g_clear_error(&error); +} + +static void application_restore_graph_viewport(Application *application) +{ + GraphNodePositionDao *dao = NULL; + GError *error = NULL; + gboolean found = FALSE; + double zoom = 0.0; + double offset_x = 0.0; + double offset_y = 0.0; + + if (application == NULL || application->graph_viewport_restored || + application->session == NULL || application->main_window == NULL) + return; + + dao = graph_node_position_dao_new( + investigation_session_get_database(application->session), &error); + if (dao != NULL && graph_node_position_dao_load_viewport( + dao, &zoom, &offset_x, &offset_y, &found, &error) && found) + main_window_set_graph_view_transform( + application->main_window, zoom, offset_x, offset_y); + else if (error != NULL) + g_warning("Impossible de restaurer le cadrage du graphe : %s", + error->message); + + application->graph_viewport_restored = TRUE; + graph_node_position_dao_free(dao); + g_clear_error(&error); +} + static void application_present_error( Application *application, const char *title, @@ -1068,6 +1122,8 @@ static void application_on_graph_loaded( application->graph_layout ); + application_restore_graph_viewport(application); + if (application->pending_relation_selection_identifier != NULL) { main_window_select_graph_relation(application->main_window, @@ -2609,12 +2665,14 @@ static gboolean application_install_session( application->tree_model ); + application_save_graph_viewport(application); investigation_session_close( application->session ); application->tree_model = new_tree_model; application->session = new_session; + application->graph_viewport_restored = FALSE; main_window_set_tree_model( application->main_window, @@ -8068,6 +8126,8 @@ void application_free( application ); + application_save_graph_viewport(application); + application_clear_graph( application ); diff --git a/src/dao/graph_node_position_dao.c b/src/dao/graph_node_position_dao.c index b2fb42c..efb87f4 100644 --- a/src/dao/graph_node_position_dao.c +++ b/src/dao/graph_node_position_dao.c @@ -71,6 +71,16 @@ static const char *const graph_node_position_dao_delete_sql = static const char *const graph_node_position_dao_delete_all_sql = "DELETE FROM graph_layout_positions;"; +static const char *const graph_node_position_dao_load_viewport_sql = + "SELECT zoom, offset_x, offset_y FROM graph_viewport WHERE id = 1;"; + +static const char *const graph_node_position_dao_upsert_viewport_sql = + "INSERT INTO graph_viewport(id, zoom, offset_x, offset_y, updated_at) " + "VALUES(1, ?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET zoom = excluded.zoom, " + "offset_x = excluded.offset_x, offset_y = excluded.offset_y, " + "updated_at = excluded.updated_at;"; + /** * @brief Enregistre une erreur littérale. */ @@ -869,3 +879,125 @@ cleanup: return success; } + +gboolean graph_node_position_dao_load_viewport( + GraphNodePositionDao *position_dao, + double *zoom, + double *offset_x, + double *offset_y, + gboolean *found, + GError **error +) +{ + DatabaseStatement *statement = NULL; + DatabaseStatementStepResult step_result; + gboolean success = FALSE; + + g_return_val_if_fail(error == NULL || *error == NULL, FALSE); + if (position_dao == NULL || position_dao->database == NULL || + zoom == NULL || offset_x == NULL || offset_y == NULL || found == NULL) + { + graph_node_position_dao_set_error_literal(error, + GRAPH_NODE_POSITION_DAO_ERROR_INVALID_ARGUMENT, + "Les paramètres de chargement du cadrage sont invalides."); + return FALSE; + } + + *found = FALSE; + statement = database_statement_prepare(position_dao->database, + graph_node_position_dao_load_viewport_sql); + if (statement == NULL) + { + graph_node_position_dao_set_database_error(position_dao, error, + GRAPH_NODE_POSITION_DAO_ERROR_PREPARE, + "Impossible de préparer le chargement du cadrage"); + return FALSE; + } + + step_result = database_statement_step(statement); + if (step_result == DATABASE_STATEMENT_STEP_ROW) + { + if (!database_statement_column_double(statement, 0, zoom) || + !database_statement_column_double(statement, 1, offset_x) || + !database_statement_column_double(statement, 2, offset_y) || + !isfinite(*zoom) || *zoom <= 0.0 || + !isfinite(*offset_x) || !isfinite(*offset_y)) + { + graph_node_position_dao_set_error_literal(error, + GRAPH_NODE_POSITION_DAO_ERROR_READ, + "Le cadrage enregistré est invalide."); + goto cleanup; + } + *found = TRUE; + } + else if (step_result == DATABASE_STATEMENT_STEP_ERROR) + { + graph_node_position_dao_set_database_error(position_dao, error, + GRAPH_NODE_POSITION_DAO_ERROR_EXECUTE, + "Impossible de charger le cadrage"); + goto cleanup; + } + success = TRUE; + +cleanup: + database_statement_finalize(statement); + return success; +} + +gboolean graph_node_position_dao_upsert_viewport( + GraphNodePositionDao *position_dao, + double zoom, + double offset_x, + double offset_y, + GError **error +) +{ + DatabaseStatement *statement = NULL; + char *updated_at = NULL; + gboolean success = FALSE; + + g_return_val_if_fail(error == NULL || *error == NULL, FALSE); + if (position_dao == NULL || position_dao->database == NULL || + !isfinite(zoom) || zoom <= 0.0 || + !isfinite(offset_x) || !isfinite(offset_y)) + { + graph_node_position_dao_set_error_literal(error, + GRAPH_NODE_POSITION_DAO_ERROR_INVALID_ARGUMENT, + "Le cadrage du graphe est invalide."); + return FALSE; + } + + updated_at = graph_node_position_dao_create_utc_timestamp(); + statement = database_statement_prepare(position_dao->database, + graph_node_position_dao_upsert_viewport_sql); + if (updated_at == NULL || statement == NULL) + { + graph_node_position_dao_set_database_error(position_dao, error, + GRAPH_NODE_POSITION_DAO_ERROR_PREPARE, + "Impossible de préparer l'enregistrement du cadrage"); + goto cleanup; + } + if (!database_statement_bind_double(statement, 1, zoom) || + !database_statement_bind_double(statement, 2, offset_x) || + !database_statement_bind_double(statement, 3, offset_y) || + !database_statement_bind_text(statement, 4, updated_at)) + { + graph_node_position_dao_set_database_error(position_dao, error, + GRAPH_NODE_POSITION_DAO_ERROR_BIND, + "Impossible de lier les données du cadrage"); + goto cleanup; + } + if (database_statement_step(statement) != DATABASE_STATEMENT_STEP_DONE) + { + graph_node_position_dao_set_database_error(position_dao, error, + GRAPH_NODE_POSITION_DAO_ERROR_EXECUTE, + "Impossible d'enregistrer le cadrage"); + goto cleanup; + } + success = TRUE; + +cleanup: + database_statement_finalize(statement); + g_free(updated_at); + return success; +} diff --git a/src/database/database.c b/src/database/database.c index 6ea91d2..1b7fa0e 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 7 +#define DATABASE_SCHEMA_VERSION_CURRENT 8 /** * @brief Version actuelle sous forme textuelle pour metadata. */ -#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "7" +#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "8" /** * @brief Nom de l'application enregistré dans les métadonnées. @@ -748,6 +748,40 @@ rollback: return false; } +static bool database_migrate_v6_to_v7(Database *database) +{ + bool transaction_started = false; + if (database == NULL || !database_transaction_begin(database)) + return false; + transaction_started = true; + if (!schema_install_v7(database) || + !database_update_schema_version(database, "7") || + !database_transaction_commit(database)) + goto rollback; + return true; +rollback: + if (transaction_started && !database_transaction_rollback(database)) + g_warning("Impossible d’annuler la migration SQLite V6 vers V7."); + return false; +} + +static bool database_migrate_v7_to_v8(Database *database) +{ + bool transaction_started = false; + if (database == NULL || !database_transaction_begin(database)) + return false; + transaction_started = true; + if (!schema_install_v8(database) || + !database_update_schema_version(database, "8") || + !database_transaction_commit(database)) + goto rollback; + return true; +rollback: + if (transaction_started && !database_transaction_rollback(database)) + g_warning("Impossible d’annuler la migration SQLite V7 vers V8."); + return false; +} + /** * @brief Garantit atomiquement la présence des extensions du schéma courant. */ @@ -959,11 +993,17 @@ bool database_migrate_to_latest( break; case 6: - if (!schema_install_v7(database)) + if (!database_migrate_v6_to_v7(database)) return false; schema_version = 7; break; + case 7: + if (!database_migrate_v7_to_v8(database)) + return false; + schema_version = 8; + break; + default: database_set_error( database, diff --git a/src/database/schema.c b/src/database/schema.c index 093af4b..fa4df0b 100644 --- a/src/database/schema.c +++ b/src/database/schema.c @@ -233,6 +233,12 @@ bool schema_install_v7(Database *database) "la migration SQLite V7"); } +bool schema_install_v8(Database *database) +{ + return schema_execute_file(database, "database/schema_v8.sql", + "la migration SQLite V8"); +} + bool schema_ensure_current( Database *database ) diff --git a/src/views/main_window.c b/src/views/main_window.c index 99cda85..84d29f1 100644 --- a/src/views/main_window.c +++ b/src/views/main_window.c @@ -1421,6 +1421,22 @@ void main_window_set_graph( ); } +gboolean main_window_get_graph_view_transform( + MainWindow *main_window, double *zoom, double *offset_x, double *offset_y) +{ + return main_window != NULL && + workspace_get_graph_view_transform( + main_window->workspace, zoom, offset_x, offset_y); +} + +void main_window_set_graph_view_transform( + MainWindow *main_window, double zoom, double offset_x, double offset_y) +{ + if (main_window != NULL) + workspace_set_graph_view_transform( + main_window->workspace, zoom, offset_x, offset_y); +} + gboolean main_window_select_graph_relation(MainWindow *main_window, const char *relation_identifier) { diff --git a/src/widgets/workspace.c b/src/widgets/workspace.c index 6d0b5cb..8221ca6 100644 --- a/src/widgets/workspace.c +++ b/src/widgets/workspace.c @@ -3229,6 +3229,24 @@ void workspace_set_graph( ); } +gboolean workspace_get_graph_view_transform( + Workspace *workspace, double *zoom, double *offset_x, double *offset_y) +{ + if (workspace == NULL || workspace->graph_state != WORKSPACE_GRAPH_STATE_READY) + return FALSE; + return investigation_graph_view_get_view_transform( + workspace->graph_view, zoom, offset_x, offset_y); +} + +void workspace_set_graph_view_transform( + Workspace *workspace, double zoom, double offset_x, double offset_y) +{ + if (workspace == NULL || workspace->graph_state != WORKSPACE_GRAPH_STATE_READY) + return; + investigation_graph_view_set_view_transform( + workspace->graph_view, zoom, offset_x, offset_y); +} + void workspace_set_graph_error( Workspace *workspace, const char *message diff --git a/tests/test_database.c b/tests/test_database.c index 977213c..d61e342 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, "7") == 0); + assert(strcmp(schema_version, "8") == 0); + 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"); test_database_assert_table_exists(database, "osint_execution_relations"); @@ -989,7 +990,7 @@ static void test_database_migrate_v1_to_v2(void) assert( strcmp( schema_version, - "6" + "8" ) == 0 );