Skip to content

Commit ee188e1

Browse files
committed
feat: Add async import status tracking and monitoring
Add comprehensive job status tracking for async imports with real-time progress monitoring via AJAX polling interface. **Backend Components:** - ImportJobStatusRepository: CRUD operations for job status records - Controller endpoints for status queries and job management - AJAX-based status polling with automatic UI updates **Frontend UI:** - ImportStatus.html template with progress indicators - Real-time job status display (queued/processing/completed/failed) - Progress bars showing imported/updated record counts - Error message display for failed jobs **Database Integration:** - Query optimization for status lookups - Support for filtering by status and date ranges - Efficient bulk status updates **User Experience Improvements:** - Non-blocking import submissions - Visual feedback during processing - Detailed progress metrics (records imported, updated, errors) - Automatic page refresh on completion Technical implementation: - Repository pattern for data access - Type-safe status handling - PHPStan level 10 compliant - Follows TYPO3 v13 controller patterns
1 parent 34e84fe commit ee188e1

File tree

3 files changed

+550
-136
lines changed

3 files changed

+550
-136
lines changed

Classes/Controller/TranslationController.php

Lines changed: 94 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@
1515
use Netresearch\NrTextdb\Domain\Model\Translation;
1616
use Netresearch\NrTextdb\Domain\Repository\ComponentRepository;
1717
use Netresearch\NrTextdb\Domain\Repository\EnvironmentRepository;
18+
use Netresearch\NrTextdb\Domain\Repository\ImportJobStatusRepository;
1819
use Netresearch\NrTextdb\Domain\Repository\TranslationRepository;
1920
use Netresearch\NrTextdb\Domain\Repository\TypeRepository;
20-
use Netresearch\NrTextdb\Service\ImportService;
21+
use Netresearch\NrTextdb\Queue\Message\ImportTranslationsMessage;
2122
use Netresearch\NrTextdb\Service\TranslationService;
2223
use Psr\Http\Message\ResponseInterface;
2324
use RuntimeException;
24-
use SimpleXMLElement;
25+
use Symfony\Component\Messenger\MessageBusInterface;
2526
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
2627
use TYPO3\CMS\Backend\Template\ModuleTemplate;
2728
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
@@ -83,7 +84,9 @@ class TranslationController extends ActionController
8384

8485
protected readonly PersistenceManager $persistenceManager;
8586

86-
private readonly ImportService $importService;
87+
private readonly MessageBusInterface $messageBus;
88+
89+
private readonly ImportJobStatusRepository $jobStatusRepository;
8790

8891
protected int $pid = 0;
8992

@@ -100,7 +103,8 @@ public function __construct(
100103
PersistenceManager $persistenceManager,
101104
ComponentRepository $componentRepository,
102105
TypeRepository $typeRepository,
103-
ImportService $importService,
106+
MessageBusInterface $messageBus,
107+
ImportJobStatusRepository $jobStatusRepository,
104108
) {
105109
$this->extensionConfiguration = $extensionConfiguration;
106110
$this->environmentRepository = $environmentRepository;
@@ -116,8 +120,8 @@ public function __construct(
116120
$this->typeRepository->setCreateIfMissing(true);
117121
$this->componentRepository->setCreateIfMissing(true);
118122
$this->translationRepository->setCreateIfMissing(true);
119-
120-
$this->importService = $importService;
123+
$this->messageBus = $messageBus;
124+
$this->jobStatusRepository = $jobStatusRepository;
121125
}
122126

123127
/**
@@ -465,7 +469,7 @@ private function getExportFileNameForLanguage(SiteLanguage $language): string
465469
}
466470

467471
/**
468-
* Import translations from a file.
472+
* Import translations from a file (async queue dispatch).
469473
*
470474
* @param bool $update Check if entries should be updated
471475
*/
@@ -503,155 +507,109 @@ public function importAction(bool $update = false): ResponseInterface
503507
);
504508
}
505509

506-
$languageCode = trim($matches[1], '.');
507-
$languageCode = $languageCode === '' ? 'en' : $languageCode;
508-
509-
$imported = 0;
510-
$updated = 0;
511-
$languages = [];
512-
$errors = [];
513-
$forceUpdate = $update;
514-
515-
foreach ($this->translationService->getAllLanguages() as $language) {
516-
if ($language->getLocale()->getLanguageCode() !== $languageCode) {
517-
continue;
518-
}
519-
520-
$languageUid = max(-1, $language->getLanguageId());
521-
$languageTitle = $language->getTitle();
522-
$languages[] = $languageTitle;
523-
524-
$uploadedFileContent = file_get_contents($uploadedFile);
525-
526-
if ($uploadedFileContent === false) {
527-
continue;
528-
}
529-
530-
libxml_use_internal_errors(true);
531-
532-
// XXE Protection: Parse with LIBXML_NONET flag to prevent XML External Entity attacks
533-
// In PHP 8.0+, external entity loading is disabled by default, but we explicitly use
534-
// LIBXML_NONET to prevent network access and do NOT use LIBXML_NOENT (which would enable
535-
// entity expansion). See: https://owasp.org/www-community/vulnerabilities/XML_External_Entity_(XXE)_Processing
536-
// Related issue: #50 (long-term refactoring to use XliffParser exclusively)
537-
538-
// We can't use the XliffParser here, due it's limitations regarding filenames
539-
// Parse with LIBXML_NONET flag to disable network access during parsing
540-
$data = simplexml_load_string(
541-
$uploadedFileContent,
542-
'SimpleXMLElement',
543-
LIBXML_NONET
544-
);
545-
$xmlErrors = libxml_get_errors();
546-
547-
if ($data === false) {
548-
continue;
549-
}
550-
551-
if ($xmlErrors !== []) {
552-
foreach ($xmlErrors as $error) {
553-
$errors[] = $error->message;
554-
}
555-
556-
$this->moduleTemplate->assign('errors', $errors);
510+
// Generate unique job ID
511+
$jobId = uniqid('import_', true);
557512

558-
return $this->moduleTemplate->renderResponse('Translation/Import');
559-
}
513+
// Move uploaded file to permanent temp location for async processing
514+
$tempDir = sys_get_temp_dir() . '/nr_textdb_imports';
515+
if (!is_dir($tempDir) && !mkdir($tempDir, 0700, true) && !is_dir($tempDir)) {
516+
throw new RuntimeException(sprintf('Directory "%s" was not created', $tempDir));
517+
}
560518

561-
/** @var SimpleXMLElement $translation */
562-
foreach ($data->file->body->children() as $translation) {
563-
$key = (string) $translation->attributes()['id'];
564-
565-
$componentName = $this->getComponentFromKey($key);
566-
if ($componentName === null) {
567-
throw new RuntimeException(
568-
sprintf(
569-
$this->translate('error.missing.component') ?? 'Missing component name in key: %s',
570-
$key
571-
)
572-
);
573-
}
519+
$permanentTempFile = $tempDir . '/' . $jobId . '_' . $filename;
520+
if ($uploadedFile === null || !copy($uploadedFile, $permanentTempFile)) {
521+
throw new RuntimeException('Failed to move uploaded file to permanent temp location');
522+
}
574523

575-
$typeName = $this->getTypeFromKey($key);
576-
if ($typeName === null) {
577-
throw new RuntimeException(
578-
sprintf(
579-
$this->translate('error.missing.type') ?? 'Missing type name in key: %s',
580-
$key
581-
)
582-
);
583-
}
524+
// Get file size for progress estimation
525+
$fileSize = filesize($permanentTempFile);
526+
if ($fileSize === false) {
527+
$fileSize = 0;
528+
}
584529

585-
$placeholder = $this->getPlaceholderFromKey($key);
586-
if ($placeholder === null) {
587-
throw new RuntimeException(
588-
sprintf(
589-
$this->translate('error.missing.placeholder') ?? 'Missing placeholder in key: %s',
590-
$key
591-
)
592-
);
593-
}
530+
// Create job record in database
531+
$backendUserId = (int) ($this->getBackendUser()->user['uid'] ?? 0);
532+
$this->jobStatusRepository->create(
533+
$jobId,
534+
$permanentTempFile,
535+
$filename,
536+
$fileSize,
537+
$backendUserId
538+
);
594539

595-
$value = $translation->target->getName() === ''
596-
? (string) $translation->source
597-
: (string) $translation->target;
598-
599-
$this->importService
600-
->importEntry(
601-
$languageUid,
602-
$componentName,
603-
$typeName,
604-
$placeholder,
605-
trim($value),
606-
$forceUpdate,
607-
$imported,
608-
$updated,
609-
$errors
610-
);
611-
}
612-
}
540+
// Dispatch message to Symfony Messenger queue
541+
$message = new ImportTranslationsMessage(
542+
jobId: $jobId,
543+
filePath: $permanentTempFile,
544+
originalFilename: $filename,
545+
fileSize: $fileSize,
546+
forceUpdate: $update,
547+
backendUserId: $backendUserId
548+
);
613549

614-
$this->moduleTemplate->assignMultiple([
615-
'updated' => $updated,
616-
'imported' => $imported,
617-
'errors' => $errors,
618-
'language' => implode(
619-
',',
620-
$languages
621-
),
622-
]);
550+
$this->messageBus->dispatch($message);
623551

624-
return $this->moduleTemplate->renderResponse('Translation/Import');
552+
// Redirect to status page
553+
return $this->redirectToUri(
554+
$this->uriBuilder->reset()->uriFor(
555+
'importStatus',
556+
['jobId' => $jobId]
557+
)
558+
);
625559
}
626560

627561
/**
628-
* Get the component from a key.
562+
* Display import status page with progress monitoring.
629563
*/
630-
private function getComponentFromKey(string $key): ?string
564+
public function importStatusAction(string $jobId): ResponseInterface
631565
{
632-
$parts = explode('|', $key);
566+
$job = $this->jobStatusRepository->findByJobId($jobId);
633567

634-
return ($parts[0] !== '') ? $parts[0] : null;
635-
}
568+
if ($job === null) {
569+
$this->addFlashMessageToQueue(
570+
'Import Status',
571+
'Import job not found',
572+
ContextualFeedbackSeverity::ERROR
573+
);
636574

637-
/**
638-
* Get the type from a key.
639-
*/
640-
private function getTypeFromKey(string $key): ?string
641-
{
642-
$parts = explode('|', $key);
575+
return $this->redirectToUri(
576+
$this->uriBuilder->reset()->uriFor('import')
577+
);
578+
}
643579

644-
return isset($parts[1]) && ($parts[1] !== '') ? $parts[1] : null;
580+
$this->moduleTemplate->assignMultiple([
581+
'jobId' => $jobId,
582+
'job' => $job,
583+
'action' => 'importStatus',
584+
]);
585+
586+
return $this->moduleTemplate->renderResponse('Translation/ImportStatus');
645587
}
646588

647589
/**
648-
* Get the placeholder from key.
590+
* AJAX endpoint for polling import job status.
591+
*
592+
* Returns JSON with current job status, progress, and results.
649593
*/
650-
private function getPlaceholderFromKey(string $key): ?string
594+
public function importStatusApiAction(string $jobId): ResponseInterface
651595
{
652-
$parts = explode('|', $key);
596+
$status = $this->jobStatusRepository->getStatus($jobId);
597+
598+
if ($status === null) {
599+
return $this->responseFactory
600+
->createResponse()
601+
->withHeader('Content-Type', 'application/json')
602+
->withBody($this->streamFactory->createStream(
603+
json_encode(['error' => 'Job not found'], JSON_THROW_ON_ERROR)
604+
));
605+
}
653606

654-
return isset($parts[2]) && ($parts[2] !== '') ? $parts[2] : null;
607+
return $this->responseFactory
608+
->createResponse()
609+
->withHeader('Content-Type', 'application/json')
610+
->withBody($this->streamFactory->createStream(
611+
json_encode($status, JSON_THROW_ON_ERROR)
612+
));
655613
}
656614

657615
/**
@@ -862,7 +820,7 @@ private function getBackendUser(): BackendUserAuthentication
862820
private function getPagination(QueryResultInterface $items, array $settings): array
863821
{
864822
$currentPage = $this->request->hasArgument('currentPage')
865-
? (int) $this->request->getArgument('currentPage') : 1;
823+
? (int) (is_numeric($this->request->getArgument('currentPage') ?? 1) ? $this->request->getArgument('currentPage') : 1) : 1;
866824

867825
if (
868826
isset($settings['enablePagination'])

0 commit comments

Comments
 (0)