diff --git a/files/client/custom/modules/data-migration/res/templates/admin/data-migration.tpl b/files/client/custom/modules/data-migration/res/templates/admin/data-migration.tpl
index af85fc5..3890a86 100644
--- a/files/client/custom/modules/data-migration/res/templates/admin/data-migration.tpl
+++ b/files/client/custom/modules/data-migration/res/templates/admin/data-migration.tpl
@@ -158,6 +158,14 @@
— {{translate 'importNoFilesHint' category='messages' scope='DataMigration'}}
+
+
+
+
diff --git a/files/client/custom/modules/data-migration/src/views/admin/data-migration.js b/files/client/custom/modules/data-migration/src/views/admin/data-migration.js
index 7e06cf1..8b70d3c 100644
--- a/files/client/custom/modules/data-migration/src/views/admin/data-migration.js
+++ b/files/client/custom/modules/data-migration/src/views/admin/data-migration.js
@@ -302,6 +302,7 @@ define('modules/data-migration/views/admin/data-migration', ['view'], function (
const preserveIds = this.$el.find('input[name="preserveIds"]').is(':checked');
const skipDuplicates = this.$el.find('input[name="skipDuplicates"]').is(':checked');
const noFiles = this.$el.find('input[name="importNoFiles"]').is(':checked');
+ const createSnapshot = !dryRun && this.$el.find('input[name="createSnapshot"]').is(':checked');
const confirmMsg = dryRun
? this.translate('dryRunConfirmation', 'messages', 'DataMigration')
@@ -313,37 +314,72 @@ define('modules/data-migration/views/admin/data-migration', ['view'], function (
.addClass('disabled').attr('disabled', true);
this.$el.find('.import-progress').show();
- Espo.Ajax.postRequest('DataMigration/action/import', {
- backupName: this.selectedBackup,
- preserveIds: preserveIds,
- noFiles: noFiles,
- skipDuplicates: skipDuplicates,
- dryRun: dryRun,
- }, {timeout: 600000})
- .then(response => {
- this.isImporting = false;
- this.$el.find('[data-action="import"], [data-action="dryRunImport"]')
- .removeClass('disabled').attr('disabled', false);
- this.$el.find('.import-progress').hide();
+ const runImport = () => {
+ Espo.Ajax.postRequest('DataMigration/action/import', {
+ backupName: this.selectedBackup,
+ preserveIds: preserveIds,
+ noFiles: noFiles,
+ skipDuplicates: skipDuplicates,
+ dryRun: dryRun,
+ }, {timeout: 600000})
+ .then(response => {
+ this.isImporting = false;
+ this.$el.find('[data-action="import"], [data-action="dryRunImport"]')
+ .removeClass('disabled').attr('disabled', false);
+ this.$el.find('.import-progress').hide();
- this.importLog = response.log || [];
- this.renderLog('.import-log-container', this.importLog, !dryRun);
+ this.importLog = response.log || [];
+ this.renderLog('.import-log-container', this.importLog, !dryRun);
- const label = dryRun ? 'Dry run completed' : 'Import completed';
+ const label = dryRun ? 'Dry run completed' : 'Import completed';
- Espo.Ui.success(this.translate(label, 'labels', 'DataMigration'));
+ Espo.Ui.success(this.translate(label, 'labels', 'DataMigration'));
- if (!dryRun) {
- this.loadStatus();
- }
- })
- .catch(xhr => {
- this.isImporting = false;
- this.$el.find('[data-action="import"], [data-action="dryRunImport"]')
- .removeClass('disabled').attr('disabled', false);
- this.$el.find('.import-progress').hide();
- Espo.Ui.error(this.translate('Import failed', 'labels', 'DataMigration'));
- });
+ if (!dryRun) {
+ this.loadStatus();
+ }
+ })
+ .catch(xhr => {
+ this.isImporting = false;
+ this.$el.find('[data-action="import"], [data-action="dryRunImport"]')
+ .removeClass('disabled').attr('disabled', false);
+ this.$el.find('.import-progress').hide();
+ Espo.Ui.error(this.translate('Import failed', 'labels', 'DataMigration'));
+ });
+ };
+
+ if (createSnapshot) {
+ Espo.Ui.notify(this.translate('Creating snapshot...', 'labels', 'DataMigration'));
+
+ Espo.Ajax.postRequest('DataMigration/action/snapshot', {}, {timeout: 300000})
+ .then(response => {
+ if (response.success) {
+ Espo.Ui.success(
+ this.translate('Snapshot created', 'labels', 'DataMigration') +
+ ': ' + response.snapshotName + ' (' + response.dumpSizeFormatted + ')'
+ );
+ runImport();
+ } else {
+ this.isImporting = false;
+ this.$el.find('[data-action="import"], [data-action="dryRunImport"]')
+ .removeClass('disabled').attr('disabled', false);
+ this.$el.find('.import-progress').hide();
+ Espo.Ui.error(
+ this.translate('Snapshot failed', 'labels', 'DataMigration') +
+ ': ' + (response.error || 'Unknown error')
+ );
+ }
+ })
+ .catch(xhr => {
+ this.isImporting = false;
+ this.$el.find('[data-action="import"], [data-action="dryRunImport"]')
+ .removeClass('disabled').attr('disabled', false);
+ this.$el.find('.import-progress').hide();
+ Espo.Ui.error(this.translate('Snapshot failed', 'labels', 'DataMigration'));
+ });
+ } else {
+ runImport();
+ }
});
},
diff --git a/files/custom/Espo/Modules/DataMigration/Api/Snapshot.php b/files/custom/Espo/Modules/DataMigration/Api/Snapshot.php
new file mode 100644
index 0000000..de4849a
--- /dev/null
+++ b/files/custom/Espo/Modules/DataMigration/Api/Snapshot.php
@@ -0,0 +1,110 @@
+user->isAdmin()) {
+ throw new Forbidden();
+ }
+
+ $timestamp = date('Ymd-His');
+ $snapshotName = 'snapshot-' . $timestamp;
+ $snapshotDir = 'data/backups/' . $snapshotName;
+
+ mkdir($snapshotDir, 0755, true);
+
+ $dbParams = $this->config->get('database');
+ $host = $dbParams['host'] ?? 'localhost';
+ $port = $dbParams['port'] ?? '3306';
+ $dbName = $dbParams['dbname'] ?? '';
+ $user = $dbParams['user'] ?? '';
+ $password = $dbParams['password'] ?? '';
+
+ $dumpFile = $snapshotDir . '/database.sql';
+
+ $cmd = sprintf(
+ 'mysqldump --host=%s --port=%s --user=%s --password=%s --single-transaction --quick %s > %s 2>&1',
+ escapeshellarg($host),
+ escapeshellarg($port),
+ escapeshellarg($user),
+ escapeshellarg($password),
+ escapeshellarg($dbName),
+ escapeshellarg($dumpFile)
+ );
+
+ $output = [];
+ $returnCode = 0;
+ exec($cmd, $output, $returnCode);
+
+ if ($returnCode !== 0) {
+ // Cleanup on failure
+ if (file_exists($dumpFile)) {
+ unlink($dumpFile);
+ }
+
+ rmdir($snapshotDir);
+
+ return ResponseComposer::json([
+ 'success' => false,
+ 'error' => 'Database dump failed: ' . implode("\n", $output),
+ ]);
+ }
+
+ $dumpSize = filesize($dumpFile);
+
+ // Write a manifest
+ $manifest = [
+ 'type' => 'snapshot',
+ 'createdAt' => date('c'),
+ 'sourceHost' => $this->config->get('siteUrl', 'unknown'),
+ 'espoVersion' => $this->config->get('version', 'unknown'),
+ 'database' => $dbName,
+ 'dumpSizeBytes' => $dumpSize,
+ ];
+
+ file_put_contents(
+ $snapshotDir . '/manifest.json',
+ json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
+ );
+
+ return ResponseComposer::json([
+ 'success' => true,
+ 'snapshotName' => $snapshotName,
+ 'dumpSize' => $dumpSize,
+ 'dumpSizeFormatted' => $this->formatBytes($dumpSize),
+ ]);
+ }
+
+ private function formatBytes(int $bytes): string
+ {
+ if ($bytes >= 1073741824) {
+ return round($bytes / 1073741824, 2) . ' GB';
+ }
+
+ if ($bytes >= 1048576) {
+ return round($bytes / 1048576, 2) . ' MB';
+ }
+
+ if ($bytes >= 1024) {
+ return round($bytes / 1024, 2) . ' KB';
+ }
+
+ return $bytes . ' B';
+ }
+}
diff --git a/files/custom/Espo/Modules/DataMigration/Resources/i18n/en_US/DataMigration.json b/files/custom/Espo/Modules/DataMigration/Resources/i18n/en_US/DataMigration.json
index 271b94c..c04e83b 100644
--- a/files/custom/Espo/Modules/DataMigration/Resources/i18n/en_US/DataMigration.json
+++ b/files/custom/Espo/Modules/DataMigration/Resources/i18n/en_US/DataMigration.json
@@ -25,6 +25,10 @@
"Exporting...": "Exporting...",
"Importing...": "Importing...",
"Log": "Log",
+ "Create snapshot before import": "Create snapshot before import",
+ "Creating snapshot...": "Creating database snapshot...",
+ "Snapshot created": "Snapshot created",
+ "Snapshot failed": "Snapshot failed",
"records": "records",
"files": "files",
"optional": "optional",
@@ -43,6 +47,7 @@
"noFilesHint": "faster export, without attachment files",
"preserveIdsHint": "keep original record IDs (recommended for same-instance restore)",
"importNoFilesHint": "import only database records, skip attachment files",
+ "snapshotHint": "backup the entire database before importing, so you can restore if needed",
"skipDuplicatesHint": "skip records that already exist in the database"
}
}
diff --git a/files/custom/Espo/Modules/DataMigration/Resources/i18n/fa_IR/DataMigration.json b/files/custom/Espo/Modules/DataMigration/Resources/i18n/fa_IR/DataMigration.json
index 6f34127..31ed5d2 100644
--- a/files/custom/Espo/Modules/DataMigration/Resources/i18n/fa_IR/DataMigration.json
+++ b/files/custom/Espo/Modules/DataMigration/Resources/i18n/fa_IR/DataMigration.json
@@ -25,6 +25,10 @@
"Exporting...": "מייצא...",
"Importing...": "מייבא...",
"Log": "לוג",
+ "Create snapshot before import": "צור צילום מצב לפני ייבוא",
+ "Creating snapshot...": "יוצר צילום מצב של מסד הנתונים...",
+ "Snapshot created": "צילום מצב נוצר",
+ "Snapshot failed": "יצירת צילום מצב נכשלה",
"records": "רשומות",
"files": "קבצים",
"optional": "אופציונלי",
@@ -43,6 +47,7 @@
"noFilesHint": "ייצוא מהיר יותר, ללא קבצים מצורפים",
"preserveIdsHint": "שמור מזהי רשומות מקוריים (מומלץ לשחזור באותה מערכת)",
"importNoFilesHint": "ייבוא רשומות בלבד, ללא קבצים מצורפים",
+ "snapshotHint": "גיבוי מלא של מסד הנתונים לפני הייבוא, כדי שתוכל לשחזר במקרה הצורך",
"skipDuplicatesHint": "דלג על רשומות שכבר קיימות במסד הנתונים"
}
}
diff --git a/files/custom/Espo/Modules/DataMigration/Resources/routes.json b/files/custom/Espo/Modules/DataMigration/Resources/routes.json
index a3657c9..1cd9cda 100644
--- a/files/custom/Espo/Modules/DataMigration/Resources/routes.json
+++ b/files/custom/Espo/Modules/DataMigration/Resources/routes.json
@@ -23,5 +23,10 @@
"route": "/DataMigration/action/deleteBackup",
"method": "post",
"actionClassName": "Espo\\Modules\\DataMigration\\Api\\DeleteBackup"
+ },
+ {
+ "route": "/DataMigration/action/snapshot",
+ "method": "post",
+ "actionClassName": "Espo\\Modules\\DataMigration\\Api\\Snapshot"
}
]
diff --git a/files/custom/Espo/Modules/DataMigration/Services/EntityImporter.php b/files/custom/Espo/Modules/DataMigration/Services/EntityImporter.php
index 981e56d..4d0c88a 100644
--- a/files/custom/Espo/Modules/DataMigration/Services/EntityImporter.php
+++ b/files/custom/Espo/Modules/DataMigration/Services/EntityImporter.php
@@ -311,9 +311,7 @@ class EntityImporter
private function insertWithRawSql(string $entityType, array $record, string $newId): void
{
- $tableName = $this->entityManager
- ->getQueryComposer()
- ->toDb($entityType);
+ $tableName = $this->camelCaseToUnderscore($entityType);
// Convert camelCase fields to snake_case for SQL
$columns = [];