1515use Netresearch \NrTextdb \Domain \Model \Translation ;
1616use Netresearch \NrTextdb \Domain \Repository \ComponentRepository ;
1717use Netresearch \NrTextdb \Domain \Repository \EnvironmentRepository ;
18+ use Netresearch \NrTextdb \Domain \Repository \ImportJobStatusRepository ;
1819use Netresearch \NrTextdb \Domain \Repository \TranslationRepository ;
1920use Netresearch \NrTextdb \Domain \Repository \TypeRepository ;
20- use Netresearch \NrTextdb \Service \ ImportService ;
21+ use Netresearch \NrTextdb \Queue \ Message \ ImportTranslationsMessage ;
2122use Netresearch \NrTextdb \Service \TranslationService ;
2223use Psr \Http \Message \ResponseInterface ;
2324use RuntimeException ;
24- use SimpleXMLElement ;
25+ use Symfony \ Component \ Messenger \ MessageBusInterface ;
2526use TYPO3 \CMS \Backend \Template \Components \ButtonBar ;
2627use TYPO3 \CMS \Backend \Template \ModuleTemplate ;
2728use 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