10 Commits

Author SHA1 Message Date
chaim 258a5b8136 release: v1.3.2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:01:28 +00:00
chaim 3dc5c4e571 fix: move Data Migration from separate 'customDev' section to 'customization'
Avoid creating separate admin panel sections for each extension.
All custom settings go under the existing "התאמה אישית" section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:01:13 +00:00
chaim 8b745bc99f release: v1.3.1 2026-03-29 21:34:25 +00:00
chaim 384d7c6df0 fix: add clientModule to module.json for client-side template resolution
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:33:48 +00:00
chaim 49275d0fc9 release: v1.3.0 2026-03-29 20:38:33 +00:00
chaim ec759b89c1 feat: snapshot management UI — list and delete database snapshots
- ListBackups API now returns snapshots alongside backups
- DeleteBackup API accepts snapshot- prefixed names
- New "Database Snapshots" panel in admin UI with delete buttons
- i18n: English + Hebrew translations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:38:33 +00:00
chaim d5fe6c21a0 release: v1.2.4 2026-03-29 20:31:11 +00:00
chaim 54e8c77325 fix: proper type coercion for raw SQL import columns
Read column metadata (type, nullable, default) from SHOW COLUMNS and
coerce values accordingly: empty string/false → 0 for NOT NULL numeric
columns, null for nullable ones. Fixes is_internal import error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:31:11 +00:00
chaim 35cda6e91c release: v1.2.3 2026-03-29 20:28:56 +00:00
chaim ba00e7ecf6 fix: convert empty strings to null for integer/bool columns
MySQL strict mode rejects empty string '' for integer columns like
is_internal. Convert '' and false to null before SQL insert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:28:56 +00:00
10 changed files with 176 additions and 10 deletions
@@ -116,6 +116,19 @@
</div>
</div>
{{! === Snapshots === }}
<div class="panel panel-info">
<div class="panel-heading">
<h4 class="panel-title">
<i class="fas fa-camera"></i>
{{translate 'Database Snapshots' scope='DataMigration'}}
</h4>
</div>
<div class="panel-body" style="padding: 0;">
<div class="snapshots-list list-group" style="margin-bottom: 0;"></div>
</div>
</div>
{{! === Import === }}
<div class="import-section" style="display: none;">
<div class="panel panel-warning">
@@ -72,6 +72,11 @@ define('modules/data-migration/views/admin/data-migration', ['view'], function (
const name = $(e.currentTarget).data('name');
this.$el.find('.backup-details[data-name="' + name + '"]').toggle();
},
'click [data-action="deleteSnapshot"]': function (e) {
e.stopPropagation();
const name = $(e.currentTarget).data('name');
this.actionDeleteSnapshot(name);
},
'click [data-action="dismissExportLog"]': function () {
this.exportLog = null;
this.$el.find('.export-log-container').hide();
@@ -144,8 +149,10 @@ define('modules/data-migration/views/admin/data-migration', ['view'], function (
Espo.Ajax.getRequest('DataMigration/action/listBackups')
.then(response => {
this.backups = response.backups || [];
this.snapshots = response.snapshots || [];
this.isLoadingBackups = false;
this.renderBackups();
this.renderSnapshots();
})
.catch(() => {
this.isLoadingBackups = false;
@@ -406,6 +413,60 @@ define('modules/data-migration/views/admin/data-migration', ['view'], function (
);
},
renderSnapshots: function () {
const $container = this.$el.find('.snapshots-list');
if (!this.snapshots || this.snapshots.length === 0) {
$container.html(
'<div class="text-muted" style="padding: 15px; text-align: center;">' +
this.translate('No snapshots', 'labels', 'DataMigration') +
'</div>'
);
return;
}
let html = '';
this.snapshots.forEach(snap => {
const date = snap.createdAt ? new Date(snap.createdAt).toLocaleString() : 'Unknown';
html += '<div class="list-group-item">';
html += '<div class="pull-right">';
html += '<button class="btn btn-danger btn-xs" data-action="deleteSnapshot" data-name="' + snap.name + '" title="' + this.translate('Delete') + '">';
html += '<i class="fas fa-trash"></i></button>';
html += '</div>';
html += '<h5 class="list-group-item-heading" style="margin: 0 0 4px;">';
html += '<i class="fas fa-camera text-info"></i> ' + snap.name;
html += '</h5>';
html += '<p class="list-group-item-text text-muted" style="margin: 0; font-size: 0.9em;">';
html += '<i class="fas fa-clock"></i> ' + date;
html += ' &nbsp;|&nbsp; <i class="fas fa-database"></i> ' + snap.database;
html += ' &nbsp;|&nbsp; <i class="fas fa-hdd"></i> ' + snap.sizeFormatted;
html += '</p>';
html += '</div>';
});
$container.html(html);
},
actionDeleteSnapshot: function (name) {
this.confirm(
this.translate('deleteSnapshotConfirmation', 'messages', 'DataMigration'),
() => {
Espo.Ajax.postRequest('DataMigration/action/deleteBackup', {
backupName: name,
})
.then(() => {
Espo.Ui.success(this.translate('Deleted'));
this.loadBackups();
})
.catch(() => {
Espo.Ui.error(this.translate('Failed to delete snapshot', 'labels', 'DataMigration'));
});
}
);
},
renderLog: function (selector, lines, isSuccess) {
const $container = this.$el.find(selector);
@@ -32,7 +32,7 @@ class DeleteBackup implements Action
// Sanitize to prevent directory traversal
$safeName = basename($backupName);
if (!str_starts_with($safeName, 'datamigration-')) {
if (!str_starts_with($safeName, 'datamigration-') && !str_starts_with($safeName, 'snapshot-')) {
throw new Forbidden('Invalid backup name.');
}
@@ -73,7 +73,43 @@ class ListBackups implements Action
];
}
return ResponseComposer::json(['backups' => $backups]);
// Scan for snapshots
$snapshots = [];
foreach ($dirs as $dir) {
if ($dir === '.' || $dir === '..') {
continue;
}
if (!str_starts_with($dir, 'snapshot-')) {
continue;
}
$fullPath = $backupsDir . '/' . $dir;
if (!is_dir($fullPath)) {
continue;
}
$manifest = $this->manifestBuilder->read($fullPath);
$dumpFile = $fullPath . '/database.sql';
$dumpSize = file_exists($dumpFile) ? filesize($dumpFile) : 0;
$snapshots[] = [
'name' => $dir,
'createdAt' => $manifest['createdAt'] ?? null,
'sourceHost' => $manifest['sourceHost'] ?? 'unknown',
'espoVersion' => $manifest['espoVersion'] ?? 'unknown',
'database' => $manifest['database'] ?? 'unknown',
'dumpSizeBytes' => $dumpSize,
'sizeFormatted' => $this->formatBytes($dumpSize),
];
}
return ResponseComposer::json([
'backups' => $backups,
'snapshots' => $snapshots,
]);
}
private function formatBytes(int $bytes): string
@@ -16,6 +16,9 @@
"Import failed": "Import failed",
"Select a backup first": "Please select a backup first",
"Failed to delete backup": "Failed to delete backup",
"Failed to delete snapshot": "Failed to delete snapshot",
"Database Snapshots": "Database Snapshots",
"No snapshots": "No snapshots found",
"Selected backup": "Selected backup",
"Import Options": "Import Options",
"Preserve original IDs": "Preserve original IDs",
@@ -44,6 +47,7 @@
"importConfirmation": "Are you sure you want to import this backup? This will add data to the database. This action cannot be easily undone.",
"dryRunConfirmation": "Run a dry import? This will simulate the import without making any changes to the database.",
"deleteBackupConfirmation": "Are you sure you want to delete this backup? This action cannot be undone.",
"deleteSnapshotConfirmation": "Are you sure you want to delete this database snapshot? This action cannot be undone.",
"noFilesHint": "faster export, without attachment files",
"preserveIdsHint": "keep original record IDs (recommended for same-instance restore)",
"importNoFilesHint": "import only database records, skip attachment files",
@@ -16,6 +16,9 @@
"Import failed": "הייבוא נכשל",
"Select a backup first": "נא לבחור גיבוי קודם",
"Failed to delete backup": "מחיקת הגיבוי נכשלה",
"Failed to delete snapshot": "מחיקת צילום המצב נכשלה",
"Database Snapshots": "צילומי מצב של מסד הנתונים",
"No snapshots": "לא נמצאו צילומי מצב",
"Selected backup": "גיבוי נבחר",
"Import Options": "אפשרויות ייבוא",
"Preserve original IDs": "שמור מזהים מקוריים",
@@ -44,6 +47,7 @@
"importConfirmation": "האם אתה בטוח שברצונך לייבא גיבוי זה? פעולה זו תוסיף נתונים למסד הנתונים ולא ניתן לבטלה בקלות.",
"dryRunConfirmation": "להריץ ייבוא ניסיון? פעולה זו תדמה את הייבוא מבלי לבצע שינויים במסד הנתונים.",
"deleteBackupConfirmation": "האם אתה בטוח שברצונך למחוק גיבוי זה? לא ניתן לבטל פעולה זו.",
"deleteSnapshotConfirmation": "האם אתה בטוח שברצונך למחוק צילום מצב זה? לא ניתן לבטל פעולה זו.",
"noFilesHint": "ייצוא מהיר יותר, ללא קבצים מצורפים",
"preserveIdsHint": "שמור מזהי רשומות מקוריים (מומלץ לשחזור באותה מערכת)",
"importNoFilesHint": "ייבוא רשומות בלבד, ללא קבצים מצורפים",
@@ -1,6 +1,5 @@
{
"customDev": {
"label": "Custom Development",
"customization": {
"itemList": [
"__APPEND__",
{
@@ -11,7 +10,6 @@
"view": "modules/data-migration/views/admin/data-migration",
"order": 50
}
],
"order": 30
]
}
}
@@ -4,5 +4,6 @@
"requires": {
"espocrm": ">=8.0.0"
},
"description": "Data migration tool for case data backup and restore"
"description": "Data migration tool for case data backup and restore",
"clientModule": "data-migration"
}
@@ -309,9 +309,12 @@ class EntityImporter
return $newId;
}
/** @var array<string, string[]> Cache of existing columns per table */
/** @var array<string, string[]> Cache of existing column names per table */
private array $tableColumnsCache = [];
/** @var array<string, array<string, array>> Cache of column metadata per table */
private array $tableColumnMetaCache = [];
/** @var array<string, string[]> Skipped columns log per entity type */
private array $skippedColumnsLog = [];
@@ -348,6 +351,9 @@ class EntityImporter
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
}
// Coerce value to match target column type
$value = $this->coerceValue($tableName, $column, $value);
$columns[] = "`{$column}`";
$values[] = $value;
$placeholders[] = '?';
@@ -372,16 +378,59 @@ class EntityImporter
$pdo = $this->entityManager->getPDO();
$sth = $pdo->query("SHOW COLUMNS FROM `{$tableName}`");
$columns = [];
$meta = [];
while ($row = $sth->fetch(\PDO::FETCH_ASSOC)) {
$columns[] = $row['Field'];
$meta[$row['Field']] = [
'type' => $row['Type'],
'nullable' => $row['Null'] === 'YES',
'default' => $row['Default'],
];
}
$this->tableColumnsCache[$tableName] = $columns;
$this->tableColumnMetaCache[$tableName] = $meta;
return $columns;
}
/**
* Coerce a value to be compatible with the target column type.
*/
private function coerceValue(string $tableName, string $column, mixed $value): mixed
{
$meta = $this->tableColumnMetaCache[$tableName][$column] ?? null;
if ($meta === null) {
return $value;
}
$type = strtolower($meta['type']);
$isNumeric = str_contains($type, 'int') || str_contains($type, 'decimal')
|| str_contains($type, 'float') || str_contains($type, 'double')
|| str_contains($type, 'tinyint') || str_contains($type, 'smallint')
|| str_contains($type, 'bigint');
if ($isNumeric && ($value === '' || $value === false)) {
if ($meta['nullable']) {
return null;
}
return 0;
}
if (!$meta['nullable'] && $value === null) {
if ($isNumeric) {
return 0;
}
return $meta['default'] ?? '';
}
return $value;
}
/**
* @return array<string, string[]> entityType => [skipped column names]
*/
+2 -2
View File
@@ -1,13 +1,13 @@
{
"name": "DataMigration",
"version": "1.2.2",
"version": "1.3.2",
"acceptableVersions": [
">=8.0.0"
],
"php": [
">=8.1"
],
"releaseDate": "2026-03-29",
"releaseDate": "2026-04-06",
"author": "klear",
"description": "Case data migration: export/import all case-related data between EspoCRM instances"
}