diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4c4ffc --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.zip 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 new file mode 100644 index 0000000..544ee60 --- /dev/null +++ b/files/client/custom/modules/data-migration/res/templates/admin/data-migration.tpl @@ -0,0 +1,179 @@ + + +
+ {{! ========== LEFT COLUMN: Status + Export ========== }} +
+ + {{! === Database Status === }} +
+
+
+ +
+

+ + {{translate 'Database Status' scope='DataMigration'}} +

+
+
+
+ +
+ +
+
+ + {{! === Export === }} +
+
+

+ + {{translate 'Export / Backup' scope='DataMigration'}} +

+
+
+

+ {{translate 'exportDescription' category='messages' scope='DataMigration'}} +

+ + {{! Status filter }} +
+ +
+ {{#each caseStatuses}} +
+ +
+ {{/each}} +
+
+ + {{! No files option }} +
+
+ +
+
+ +
+ +
+ + + + +
+
+
+ + {{! ========== RIGHT COLUMN: Backups + Import ========== }} +
+ + {{! === Backup List === }} +
+
+
+ +
+

+ + {{translate 'Available Backups' scope='DataMigration'}} +

+
+
+
+ +
+
+
+
+ + {{! === Import === }} + +
+
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 new file mode 100644 index 0000000..ee96321 --- /dev/null +++ b/files/client/custom/modules/data-migration/src/views/admin/data-migration.js @@ -0,0 +1,427 @@ +define('modules/data-migration/views/admin/data-migration', ['view'], function (Dep) { + + const Parent = Dep.__esModule ? Dep.default : Dep; + + return Parent.extend({ + + template: 'data-migration:admin/data-migration', + + isExporting: false, + isImporting: false, + isLoadingStatus: false, + isLoadingBackups: false, + + data: function () { + return { + isExporting: this.isExporting, + isImporting: this.isImporting, + isLoadingStatus: this.isLoadingStatus, + statusData: this.statusData, + backups: this.backups, + selectedBackup: this.selectedBackup, + exportLog: this.exportLog, + importLog: this.importLog, + caseStatuses: this.caseStatuses, + }; + }, + + setup: function () { + this.statusData = null; + this.backups = []; + this.selectedBackup = null; + this.exportLog = null; + this.importLog = null; + + this.caseStatuses = [ + 'Active', 'Pending', 'Closed', 'Rejected', + 'On Hold', 'Won', 'Lost', 'Archived', + ]; + + this.once('after:render', () => { + this.loadStatus(); + this.loadBackups(); + }); + }, + + events: { + 'click [data-action="export"]': function () { + this.actionExport(); + }, + 'click [data-action="import"]': function () { + this.actionImport(); + }, + 'click [data-action="dryRunImport"]': function () { + this.actionImport(true); + }, + 'click [data-action="refreshStatus"]': function () { + this.loadStatus(); + }, + 'click [data-action="refreshBackups"]': function () { + this.loadBackups(); + }, + 'click [data-action="selectBackup"]': function (e) { + const name = $(e.currentTarget).data('name'); + this.selectBackup(name); + }, + 'click [data-action="deleteBackup"]': function (e) { + e.stopPropagation(); + const name = $(e.currentTarget).data('name'); + this.actionDeleteBackup(name); + }, + 'click [data-action="toggleBackupDetails"]': function (e) { + const name = $(e.currentTarget).data('name'); + this.$el.find('.backup-details[data-name="' + name + '"]').toggle(); + }, + 'click [data-action="dismissExportLog"]': function () { + this.exportLog = null; + this.$el.find('.export-log-container').hide(); + }, + 'click [data-action="dismissImportLog"]': function () { + this.importLog = null; + this.$el.find('.import-log-container').hide(); + }, + }, + + loadStatus: function () { + this.isLoadingStatus = true; + this.$el.find('.status-spinner').show(); + this.$el.find('.status-content').hide(); + + Espo.Ajax.getRequest('DataMigration/action/status') + .then(response => { + this.statusData = response; + this.isLoadingStatus = false; + this.renderStatus(); + }) + .catch(() => { + this.isLoadingStatus = false; + this.$el.find('.status-spinner').hide(); + Espo.Ui.error('Failed to load status'); + }); + }, + + renderStatus: function () { + const $container = this.$el.find('.status-content'); + const $spinner = this.$el.find('.status-spinner'); + $spinner.hide(); + + if (!this.statusData) { + $container.html('

No data

').show(); + return; + } + + let html = ''; + html += ''; + html += ''; + + let total = 0; + const counts = this.statusData.entityCounts || {}; + + for (const type in counts) { + if (counts[type] > 0) { + html += ''; + total += counts[type]; + } + } + + html += ''; + html += ''; + html += '
' + this.translate('Entity Type') + '' + this.translate('Count') + '
' + type + '' + counts[type].toLocaleString() + '
' + this.translate('Total') + '' + total.toLocaleString() + '
'; + + html += '

'; + html += ' '; + html += this.translate('Attachments') + ': ' + (this.statusData.attachmentFiles || 0).toLocaleString(); + html += ' (' + (this.statusData.attachmentSizeFormatted || '0 B') + ')'; + html += '

'; + + $container.html(html).show(); + }, + + loadBackups: function () { + this.isLoadingBackups = true; + this.$el.find('.backups-spinner').show(); + + Espo.Ajax.getRequest('DataMigration/action/listBackups') + .then(response => { + this.backups = response.backups || []; + this.isLoadingBackups = false; + this.renderBackups(); + }) + .catch(() => { + this.isLoadingBackups = false; + this.$el.find('.backups-spinner').hide(); + Espo.Ui.error('Failed to load backups'); + }); + }, + + renderBackups: function () { + const $container = this.$el.find('.backups-list'); + const $spinner = this.$el.find('.backups-spinner'); + $spinner.hide(); + + if (!this.backups || this.backups.length === 0) { + $container.html( + '
' + + '' + + this.translate('No backups found', 'labels', 'DataMigration') + + '
' + ); + return; + } + + let html = ''; + + this.backups.forEach(backup => { + const isSelected = this.selectedBackup === backup.name; + const selectedClass = isSelected ? ' active' : ''; + const date = backup.exportedAt ? new Date(backup.exportedAt).toLocaleString() : 'Unknown'; + + let totalRecords = 0; + const ec = backup.entityCounts || {}; + + for (const k in ec) { + totalRecords += ec[k]; + } + + html += '
'; + html += '
'; + html += ''; + html += '
'; + html += '
'; + html += ' '; + html += '' + backup.name + ''; + html += '
'; + html += '

'; + html += ' ' + date; + html += '  |  ' + totalRecords.toLocaleString() + ' ' + this.translate('records', 'labels', 'DataMigration'); + html += '  |  ' + (backup.totalAttachmentFiles || 0).toLocaleString() + ' ' + this.translate('files', 'labels', 'DataMigration'); + html += ' (' + (backup.sizeFormatted || '0 B') + ')'; + html += '

'; + + // Details (hidden by default) + html += ''; + html += '
'; + }); + + $container.html(html); + }, + + selectBackup: function (name) { + if (this.selectedBackup === name) { + this.selectedBackup = null; + } else { + this.selectedBackup = name; + } + + this.$el.find('.backup-item').removeClass('active'); + + if (this.selectedBackup) { + this.$el.find('.backup-item[data-name="' + name + '"]').addClass('active'); + this.$el.find('.import-section').show(); + this.$el.find('.selected-backup-name').text(name); + } else { + this.$el.find('.import-section').hide(); + } + }, + + actionExport: function () { + const statusFilters = []; + + this.$el.find('.export-status-checkboxes input:checked').each(function () { + statusFilters.push($(this).val()); + }); + + const noFiles = this.$el.find('input[name="noFiles"]').is(':checked'); + + this.confirm( + this.translate('exportConfirmation', 'messages', 'DataMigration'), + () => { + this.isExporting = true; + this.$el.find('[data-action="export"]').addClass('disabled').attr('disabled', true); + this.$el.find('.export-progress').show(); + + Espo.Ajax.postRequest('DataMigration/action/export', { + caseStatus: statusFilters.length > 0 ? statusFilters : null, + noFiles: noFiles, + }, {timeout: 600000}) + .then(response => { + this.isExporting = false; + this.$el.find('[data-action="export"]').removeClass('disabled').attr('disabled', false); + this.$el.find('.export-progress').hide(); + + this.exportLog = response.log || []; + this.renderLog('.export-log-container', this.exportLog, true); + + Espo.Ui.success(this.translate('Export completed', 'labels', 'DataMigration')); + this.loadBackups(); + this.loadStatus(); + }) + .catch(xhr => { + this.isExporting = false; + this.$el.find('[data-action="export"]').removeClass('disabled').attr('disabled', false); + this.$el.find('.export-progress').hide(); + Espo.Ui.error(this.translate('Export failed', 'labels', 'DataMigration')); + }); + } + ); + }, + + actionImport: function (dryRun) { + dryRun = dryRun || false; + + if (!this.selectedBackup) { + Espo.Ui.warning(this.translate('Select a backup first', 'labels', 'DataMigration')); + return; + } + + const preserveIds = this.$el.find('input[name="preserveIds"]').is(':checked'); + const skipDuplicates = this.$el.find('input[name="skipDuplicates"]').is(':checked'); + + const confirmMsg = dryRun + ? this.translate('dryRunConfirmation', 'messages', 'DataMigration') + : this.translate('importConfirmation', 'messages', 'DataMigration'); + + this.confirm(confirmMsg, () => { + this.isImporting = true; + this.$el.find('[data-action="import"], [data-action="dryRunImport"]') + .addClass('disabled').attr('disabled', true); + this.$el.find('.import-progress').show(); + + Espo.Ajax.postRequest('DataMigration/action/import', { + backupName: this.selectedBackup, + preserveIds: preserveIds, + 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); + + const label = dryRun ? 'Dry run completed' : 'Import completed'; + + 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')); + }); + }); + }, + + actionDeleteBackup: function (name) { + this.confirm( + this.translate('deleteBackupConfirmation', 'messages', 'DataMigration'), + () => { + Espo.Ajax.postRequest('DataMigration/action/deleteBackup', { + backupName: name, + }) + .then(() => { + if (this.selectedBackup === name) { + this.selectedBackup = null; + this.$el.find('.import-section').hide(); + } + + Espo.Ui.success(this.translate('Deleted')); + this.loadBackups(); + }) + .catch(() => { + Espo.Ui.error(this.translate('Failed to delete backup', 'labels', 'DataMigration')); + }); + } + ); + }, + + renderLog: function (selector, lines, isSuccess) { + const $container = this.$el.find(selector); + + if (!lines || lines.length === 0) { + $container.hide(); + return; + } + + const panelClass = isSuccess ? 'panel-success' : 'panel-info'; + const dismissAction = selector.includes('export') ? 'dismissExportLog' : 'dismissImportLog'; + + let html = '
'; + html += '
'; + html += ''; + html += '

' + this.translate('Log', 'labels', 'DataMigration') + '

'; + html += '
'; + html += '
'; + html += '
';
+
+            lines.forEach(line => {
+                let lineClass = '';
+
+                if (line.includes('ERROR')) {
+                    lineClass = 'text-danger';
+                } else if (line.includes('WARNING')) {
+                    lineClass = 'text-warning';
+                } else if (line.includes('===') || line.includes('Complete')) {
+                    lineClass = 'text-success';
+                }
+
+                if (lineClass) {
+                    html += '' + this.escapeHtml(line) + '\n';
+                } else {
+                    html += this.escapeHtml(line) + '\n';
+                }
+            });
+
+            html += '
'; + + $container.html(html).show(); + }, + + escapeHtml: function (text) { + const div = document.createElement('div'); + div.textContent = text; + + return div.innerHTML; + }, + + confirm: function (message, callback) { + Espo.Ui.confirm(message, { + confirmText: this.translate('Yes'), + cancelText: this.translate('No'), + }, callback); + }, + }); +}); diff --git a/files/custom/Espo/Modules/DataMigration/Api/DeleteBackup.php b/files/custom/Espo/Modules/DataMigration/Api/DeleteBackup.php new file mode 100644 index 0000000..50804b6 --- /dev/null +++ b/files/custom/Espo/Modules/DataMigration/Api/DeleteBackup.php @@ -0,0 +1,73 @@ +user->isAdmin()) { + throw new Forbidden(); + } + + $body = $request->getParsedBody(); + $backupName = $body->backupName ?? null; + + if (!$backupName) { + throw new BadRequest('backupName is required.'); + } + + // Sanitize to prevent directory traversal + $safeName = basename($backupName); + + if (!str_starts_with($safeName, 'datamigration-')) { + throw new Forbidden('Invalid backup name.'); + } + + $fullPath = 'data/backups/' . $safeName; + + if (!is_dir($fullPath)) { + throw new BadRequest("Backup not found: {$safeName}"); + } + + $this->deleteDirectory($fullPath); + + return ResponseComposer::json([ + 'success' => true, + 'deleted' => $safeName, + ]); + } + + private function deleteDirectory(string $dir): void + { + $items = scandir($dir); + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $dir . '/' . $item; + + if (is_dir($path)) { + $this->deleteDirectory($path); + } else { + unlink($path); + } + } + + rmdir($dir); + } +} diff --git a/files/custom/Espo/Modules/DataMigration/Api/Export.php b/files/custom/Espo/Modules/DataMigration/Api/Export.php new file mode 100644 index 0000000..e53ecfc --- /dev/null +++ b/files/custom/Espo/Modules/DataMigration/Api/Export.php @@ -0,0 +1,53 @@ +user->isAdmin()) { + throw new Forbidden(); + } + + $body = $request->getParsedBody(); + + $caseIds = $body->caseIds ?? null; + $caseStatus = $body->caseStatus ?? null; + $noFiles = $body->noFiles ?? false; + + $timestamp = date('Ymd-His'); + $outputDir = 'data/backups/datamigration-' . $timestamp; + + $io = new BufferedIO(); + + $this->exportService->export($outputDir, [ + 'batchSize' => 500, + 'caseIds' => $caseIds, + 'caseStatus' => is_array($caseStatus) && !empty($caseStatus) ? $caseStatus : null, + 'noFiles' => $noFiles, + 'verbose' => true, + ], $io); + + return ResponseComposer::json([ + 'success' => true, + 'outputDir' => $outputDir, + 'backupName' => 'datamigration-' . $timestamp, + 'log' => $io->getLines(), + ]); + } +} diff --git a/files/custom/Espo/Modules/DataMigration/Api/Import.php b/files/custom/Espo/Modules/DataMigration/Api/Import.php new file mode 100644 index 0000000..d4debd7 --- /dev/null +++ b/files/custom/Espo/Modules/DataMigration/Api/Import.php @@ -0,0 +1,67 @@ +user->isAdmin()) { + throw new Forbidden(); + } + + $body = $request->getParsedBody(); + + $backupName = $body->backupName ?? null; + + if (!$backupName) { + throw new BadRequest('backupName is required.'); + } + + $inputDir = 'data/backups/' . basename($backupName); + + if (!is_dir($inputDir)) { + throw new BadRequest("Backup not found: {$backupName}"); + } + + if (!file_exists($inputDir . '/manifest.json')) { + throw new BadRequest("Invalid backup: no manifest.json found."); + } + + $preserveIds = $body->preserveIds ?? true; + $skipDuplicates = $body->skipDuplicates ?? true; + $dryRun = $body->dryRun ?? false; + + $io = new BufferedIO(); + + $this->importService->import($inputDir, [ + 'batchSize' => 500, + 'preserveIds' => $preserveIds, + 'skipDuplicates' => $skipDuplicates, + 'dryRun' => $dryRun, + 'verbose' => true, + ], $io); + + return ResponseComposer::json([ + 'success' => true, + 'backupName' => $backupName, + 'dryRun' => $dryRun, + 'log' => $io->getLines(), + ]); + } +} diff --git a/files/custom/Espo/Modules/DataMigration/Api/ListBackups.php b/files/custom/Espo/Modules/DataMigration/Api/ListBackups.php new file mode 100644 index 0000000..6648537 --- /dev/null +++ b/files/custom/Espo/Modules/DataMigration/Api/ListBackups.php @@ -0,0 +1,95 @@ +user->isAdmin()) { + throw new Forbidden(); + } + + $backupsDir = 'data/backups'; + $backups = []; + + if (!is_dir($backupsDir)) { + return ResponseComposer::json(['backups' => []]); + } + + $dirs = scandir($backupsDir, SCANDIR_SORT_DESCENDING); + + foreach ($dirs as $dir) { + if ($dir === '.' || $dir === '..') { + continue; + } + + if (!str_starts_with($dir, 'datamigration-')) { + continue; + } + + $fullPath = $backupsDir . '/' . $dir; + + if (!is_dir($fullPath)) { + continue; + } + + $manifest = $this->manifestBuilder->read($fullPath); + + if ($manifest === null) { + continue; + } + + $totalRecords = 0; + + foreach ($manifest['entityCounts'] ?? [] as $count) { + $totalRecords += $count; + } + + $backups[] = [ + 'name' => $dir, + 'exportedAt' => $manifest['exportedAt'] ?? null, + 'sourceHost' => $manifest['sourceHost'] ?? 'unknown', + 'espoVersion' => $manifest['espoVersion'] ?? 'unknown', + 'totalRecords' => $totalRecords, + 'totalAttachmentFiles' => $manifest['totalAttachmentFiles'] ?? 0, + 'totalAttachmentSizeBytes' => $manifest['totalAttachmentSizeBytes'] ?? 0, + 'entityCounts' => $manifest['entityCounts'] ?? [], + 'modules' => $manifest['modules'] ?? [], + 'sizeFormatted' => $this->formatBytes($manifest['totalAttachmentSizeBytes'] ?? 0), + ]; + } + + return ResponseComposer::json(['backups' => $backups]); + } + + 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/Api/Status.php b/files/custom/Espo/Modules/DataMigration/Api/Status.php new file mode 100644 index 0000000..053a1a7 --- /dev/null +++ b/files/custom/Espo/Modules/DataMigration/Api/Status.php @@ -0,0 +1,122 @@ +user->isAdmin()) { + throw new Forbidden(); + } + + $entityCounts = []; + $total = 0; + + foreach (self::ENTITY_TYPES as $entityType) { + try { + $count = $this->entityManager + ->getRDBRepository($entityType) + ->count(); + + $entityCounts[$entityType] = $count; + $total += $count; + } catch (\Exception $e) { + // Entity type doesn't exist + } + } + + $attachmentInfo = $this->getAttachmentInfo(); + + return ResponseComposer::json([ + 'entityCounts' => $entityCounts, + 'totalRecords' => $total, + 'attachmentFiles' => $attachmentInfo['count'], + 'attachmentSize' => $attachmentInfo['size'], + 'attachmentSizeFormatted' => $this->formatBytes($attachmentInfo['size']), + ]); + } + + private function getAttachmentInfo(): array + { + $uploadDir = 'data/upload/'; + $count = 0; + $size = 0; + + if (is_dir($uploadDir)) { + $files = scandir($uploadDir); + + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + + $path = $uploadDir . $file; + + if (is_file($path)) { + $count++; + $size += filesize($path); + } + } + } + + return ['count' => $count, 'size' => $size]; + } + + 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/Admin.json b/files/custom/Espo/Modules/DataMigration/Resources/i18n/en_US/Admin.json new file mode 100644 index 0000000..5c25aac --- /dev/null +++ b/files/custom/Espo/Modules/DataMigration/Resources/i18n/en_US/Admin.json @@ -0,0 +1,8 @@ +{ + "labels": { + "Data Migration": "Data Migration" + }, + "descriptions": { + "dataMigration": "Export and import case data between EspoCRM instances" + } +} 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 new file mode 100644 index 0000000..a7776b7 --- /dev/null +++ b/files/custom/Espo/Modules/DataMigration/Resources/i18n/en_US/DataMigration.json @@ -0,0 +1,47 @@ +{ + "labels": { + "Data Migration": "Data Migration", + "Database Status": "Database Status", + "Export / Backup": "Export / Backup", + "Import / Restore": "Import / Restore", + "Available Backups": "Available Backups", + "No backups found": "No backups found", + "Start Export": "Start Export", + "Import Now": "Import Now", + "Dry Run": "Dry Run", + "Export completed": "Export completed successfully", + "Import completed": "Import completed successfully", + "Dry run completed": "Dry run completed successfully", + "Export failed": "Export failed", + "Import failed": "Import failed", + "Select a backup first": "Please select a backup first", + "Failed to delete backup": "Failed to delete backup", + "Selected backup": "Selected backup", + "Import Options": "Import Options", + "Preserve original IDs": "Preserve original IDs", + "Skip duplicates": "Skip duplicates", + "Skip attachment files": "Skip attachment files", + "Filter by Case Status": "Filter by Case Status", + "Exporting...": "Exporting...", + "Importing...": "Importing...", + "Log": "Log", + "records": "records", + "files": "files", + "optional": "optional", + "Entity Type": "Entity Type", + "Count": "Count", + "Total": "Total", + "Attachments": "Attachments", + "Modules": "Modules" + }, + "messages": { + "exportDescription": "Export all case-related data to a backup file. You can filter by case status or export everything.", + "exportConfirmation": "Are you sure you want to start the export? This may take a while depending on the amount of data.", + "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.", + "noFilesHint": "faster export, without attachment files", + "preserveIdsHint": "keep original record IDs (recommended for same-instance restore)", + "skipDuplicatesHint": "skip records that already exist in the database" + } +} diff --git a/files/custom/Espo/Modules/DataMigration/Resources/i18n/fa_IR/Admin.json b/files/custom/Espo/Modules/DataMigration/Resources/i18n/fa_IR/Admin.json new file mode 100644 index 0000000..e3d82f2 --- /dev/null +++ b/files/custom/Espo/Modules/DataMigration/Resources/i18n/fa_IR/Admin.json @@ -0,0 +1,8 @@ +{ + "labels": { + "Data Migration": "העברת נתונים" + }, + "descriptions": { + "dataMigration": "ייצוא וייבוא נתוני תיקים בין מערכות EspoCRM" + } +} 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 new file mode 100644 index 0000000..e8be9f2 --- /dev/null +++ b/files/custom/Espo/Modules/DataMigration/Resources/i18n/fa_IR/DataMigration.json @@ -0,0 +1,47 @@ +{ + "labels": { + "Data Migration": "העברת נתונים", + "Database Status": "מצב מסד הנתונים", + "Export / Backup": "ייצוא / גיבוי", + "Import / Restore": "ייבוא / שחזור", + "Available Backups": "גיבויים זמינים", + "No backups found": "לא נמצאו גיבויים", + "Start Export": "התחל ייצוא", + "Import Now": "ייבוא עכשיו", + "Dry Run": "הרצת ניסיון", + "Export completed": "הייצוא הושלם בהצלחה", + "Import completed": "הייבוא הושלם בהצלחה", + "Dry run completed": "הרצת הניסיון הושלמה בהצלחה", + "Export failed": "הייצוא נכשל", + "Import failed": "הייבוא נכשל", + "Select a backup first": "נא לבחור גיבוי קודם", + "Failed to delete backup": "מחיקת הגיבוי נכשלה", + "Selected backup": "גיבוי נבחר", + "Import Options": "אפשרויות ייבוא", + "Preserve original IDs": "שמור מזהים מקוריים", + "Skip duplicates": "דלג על כפילויות", + "Skip attachment files": "דלג על קבצים מצורפים", + "Filter by Case Status": "סנן לפי סטטוס תיק", + "Exporting...": "מייצא...", + "Importing...": "מייבא...", + "Log": "לוג", + "records": "רשומות", + "files": "קבצים", + "optional": "אופציונלי", + "Entity Type": "סוג ישות", + "Count": "כמות", + "Total": "סה\"כ", + "Attachments": "קבצים מצורפים", + "Modules": "מודולים" + }, + "messages": { + "exportDescription": "ייצוא כל הנתונים הקשורים לתיקים לקובץ גיבוי. ניתן לסנן לפי סטטוס תיק או לייצא הכל.", + "exportConfirmation": "האם אתה בטוח שברצונך להתחיל את הייצוא? פעולה זו עשויה לקחת זמן בהתאם לכמות הנתונים.", + "importConfirmation": "האם אתה בטוח שברצונך לייבא גיבוי זה? פעולה זו תוסיף נתונים למסד הנתונים ולא ניתן לבטלה בקלות.", + "dryRunConfirmation": "להריץ ייבוא ניסיון? פעולה זו תדמה את הייבוא מבלי לבצע שינויים במסד הנתונים.", + "deleteBackupConfirmation": "האם אתה בטוח שברצונך למחוק גיבוי זה? לא ניתן לבטל פעולה זו.", + "noFilesHint": "ייצוא מהיר יותר, ללא קבצים מצורפים", + "preserveIdsHint": "שמור מזהי רשומות מקוריים (מומלץ לשחזור באותה מערכת)", + "skipDuplicatesHint": "דלג על רשומות שכבר קיימות במסד הנתונים" + } +} diff --git a/files/custom/Espo/Modules/DataMigration/Resources/metadata/app/adminPanel.json b/files/custom/Espo/Modules/DataMigration/Resources/metadata/app/adminPanel.json new file mode 100644 index 0000000..0dc9927 --- /dev/null +++ b/files/custom/Espo/Modules/DataMigration/Resources/metadata/app/adminPanel.json @@ -0,0 +1,14 @@ +{ + "system": { + "itemList": [ + { + "url": "#Admin/dataMigration", + "label": "Data Migration", + "iconClass": "fas fa-exchange-alt", + "description": "dataMigration", + "view": "modules/data-migration/views/admin/data-migration", + "order": 85 + } + ] + } +} diff --git a/files/custom/Espo/Modules/DataMigration/Resources/routes.json b/files/custom/Espo/Modules/DataMigration/Resources/routes.json new file mode 100644 index 0000000..a3657c9 --- /dev/null +++ b/files/custom/Espo/Modules/DataMigration/Resources/routes.json @@ -0,0 +1,27 @@ +[ + { + "route": "/DataMigration/action/status", + "method": "get", + "actionClassName": "Espo\\Modules\\DataMigration\\Api\\Status" + }, + { + "route": "/DataMigration/action/export", + "method": "post", + "actionClassName": "Espo\\Modules\\DataMigration\\Api\\Export" + }, + { + "route": "/DataMigration/action/import", + "method": "post", + "actionClassName": "Espo\\Modules\\DataMigration\\Api\\Import" + }, + { + "route": "/DataMigration/action/listBackups", + "method": "get", + "actionClassName": "Espo\\Modules\\DataMigration\\Api\\ListBackups" + }, + { + "route": "/DataMigration/action/deleteBackup", + "method": "post", + "actionClassName": "Espo\\Modules\\DataMigration\\Api\\DeleteBackup" + } +] diff --git a/files/custom/Espo/Modules/DataMigration/Services/BufferedIO.php b/files/custom/Espo/Modules/DataMigration/Services/BufferedIO.php new file mode 100644 index 0000000..15382e2 --- /dev/null +++ b/files/custom/Espo/Modules/DataMigration/Services/BufferedIO.php @@ -0,0 +1,43 @@ +lines[] = $string; + } + + public function writeLine(string $string): void + { + $this->lines[] = $string; + } + + public function readLine(): string + { + return ''; + } + + /** + * @return string[] + */ + public function getLines(): array + { + return $this->lines; + } + + public function getOutput(): string + { + return implode("\n", $this->lines); + } +}