fix(graph): restore viewport when reopening

This commit is contained in:
grayTerminal-sh 2026-07-23 14:17:23 +02:00
parent f54af89e3e
commit 6b43379e75
14 changed files with 330 additions and 6 deletions

View file

@ -1,7 +1,7 @@
/****************************************************************************** /******************************************************************************
* Labfy Investigation * 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) 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. */ /* Migration idempotente des positions d'entités déjà enregistrées. */
INSERT OR IGNORE INTO graph_layout_positions(node_id, x, y, updated_at) INSERT OR IGNORE INTO graph_layout_positions(node_id, x, y, updated_at)
SELECT entity_id, x, y, updated_at SELECT entity_id, x, y, updated_at

8
database/schema_v8.sql Normal file
View file

@ -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)
);

View file

@ -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, 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é. 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 # 12. Conclusion

View file

@ -143,6 +143,23 @@ gboolean graph_node_position_dao_delete_all(
GError **error 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 G_END_DECLS
#endif #endif

View file

@ -88,6 +88,7 @@ bool schema_install_v5(Database *database);
bool schema_install_v6(Database *database); bool schema_install_v6(Database *database);
bool schema_install_v7(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. * @brief Garantit la présence des extensions du schéma courant V2.

View file

@ -526,6 +526,11 @@ void main_window_set_graph(
const InvestigationGraphLayout *graph_layout 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é. */ /** @brief Sélectionne une relation dans le graphe affiché. */
gboolean main_window_select_graph_relation( gboolean main_window_select_graph_relation(
MainWindow *main_window, MainWindow *main_window,

View file

@ -447,6 +447,11 @@ void workspace_set_graph(
const InvestigationGraphLayout *graph_layout 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. * @brief Affiche une erreur de chargement du graphe.
* *

View file

@ -137,6 +137,7 @@ struct Application
InvestigationGraphModel *graph_model; InvestigationGraphModel *graph_model;
InvestigationGraphLayout *graph_layout; InvestigationGraphLayout *graph_layout;
guint64 graph_load_generation; guint64 graph_load_generation;
gboolean graph_viewport_restored;
char *selected_evidence_identifier; char *selected_evidence_identifier;
char *pending_relation_selection_identifier; char *pending_relation_selection_identifier;
@ -252,6 +253,59 @@ static void application_start_graph_loading(
const char *database_path 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( static void application_present_error(
Application *application, Application *application,
const char *title, const char *title,
@ -1068,6 +1122,8 @@ static void application_on_graph_loaded(
application->graph_layout application->graph_layout
); );
application_restore_graph_viewport(application);
if (application->pending_relation_selection_identifier != NULL) if (application->pending_relation_selection_identifier != NULL)
{ {
main_window_select_graph_relation(application->main_window, main_window_select_graph_relation(application->main_window,
@ -2609,12 +2665,14 @@ static gboolean application_install_session(
application->tree_model application->tree_model
); );
application_save_graph_viewport(application);
investigation_session_close( investigation_session_close(
application->session application->session
); );
application->tree_model = new_tree_model; application->tree_model = new_tree_model;
application->session = new_session; application->session = new_session;
application->graph_viewport_restored = FALSE;
main_window_set_tree_model( main_window_set_tree_model(
application->main_window, application->main_window,
@ -8068,6 +8126,8 @@ void application_free(
application application
); );
application_save_graph_viewport(application);
application_clear_graph( application_clear_graph(
application application
); );

View file

@ -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 = static const char *const graph_node_position_dao_delete_all_sql =
"DELETE FROM graph_layout_positions;"; "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. * @brief Enregistre une erreur littérale.
*/ */
@ -869,3 +879,125 @@ cleanup:
return success; 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;
}

View file

@ -16,12 +16,12 @@
/** /**
* @brief Version actuelle du schéma SQLite. * @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. * @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. * @brief Nom de l'application enregistré dans les métadonnées.
@ -748,6 +748,40 @@ rollback:
return false; 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 dannuler 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 dannuler la migration SQLite V7 vers V8.");
return false;
}
/** /**
* @brief Garantit atomiquement la présence des extensions du schéma courant. * @brief Garantit atomiquement la présence des extensions du schéma courant.
*/ */
@ -959,11 +993,17 @@ bool database_migrate_to_latest(
break; break;
case 6: case 6:
if (!schema_install_v7(database)) if (!database_migrate_v6_to_v7(database))
return false; return false;
schema_version = 7; schema_version = 7;
break; break;
case 7:
if (!database_migrate_v7_to_v8(database))
return false;
schema_version = 8;
break;
default: default:
database_set_error( database_set_error(
database, database,

View file

@ -233,6 +233,12 @@ bool schema_install_v7(Database *database)
"la migration SQLite V7"); "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( bool schema_ensure_current(
Database *database Database *database
) )

View file

@ -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, gboolean main_window_select_graph_relation(MainWindow *main_window,
const char *relation_identifier) const char *relation_identifier)
{ {

View file

@ -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( void workspace_set_graph_error(
Workspace *workspace, Workspace *workspace,
const char *message const char *message

View file

@ -525,7 +525,8 @@ static void test_database_initialize_valid_database(void)
"FROM investigation;" "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_executions");
test_database_assert_table_exists(database, "osint_execution_entities"); test_database_assert_table_exists(database, "osint_execution_entities");
test_database_assert_table_exists(database, "osint_execution_relations"); test_database_assert_table_exists(database, "osint_execution_relations");
@ -989,7 +990,7 @@ static void test_database_migrate_v1_to_v2(void)
assert( assert(
strcmp( strcmp(
schema_version, schema_version,
"6" "8"
) == 0 ) == 0
); );