From 7c8f6b6b7a8b3f4906db956032d2a6044d799780 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:00:49 +0200 Subject: [PATCH 01/52] [TASK] Update class to readonly and remove redundant readonly property in constructor --- .../AfterContentObjectRendererInitializedEventListener.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Classes/EventListener/AfterContentObjectRendererInitializedEventListener.php b/Classes/EventListener/AfterContentObjectRendererInitializedEventListener.php index 9771ae6..812af68 100644 --- a/Classes/EventListener/AfterContentObjectRendererInitializedEventListener.php +++ b/Classes/EventListener/AfterContentObjectRendererInitializedEventListener.php @@ -23,9 +23,9 @@ #[AsEventListener( identifier: 'jwtools2/afterContentObjectRendererInitialized', )] -final class AfterContentObjectRendererInitializedEventListener +final readonly class AfterContentObjectRendererInitializedEventListener { - public function __construct(private readonly ExtensionConfiguration $extensionConfiguration) {} + public function __construct(private ExtensionConfiguration $extensionConfiguration) {} public function __invoke(AfterContentObjectRendererInitializedEvent $event): void { @@ -48,7 +48,7 @@ protected function getConfiguration(): array { try { return $this->extensionConfiguration->get('jwtools2'); - } catch (ExtensionConfigurationExtensionNotConfiguredException|ExtensionConfigurationPathDoesNotExistException $exception) { + } catch (ExtensionConfigurationExtensionNotConfiguredException|ExtensionConfigurationPathDoesNotExistException) { return []; } } From dd3d3bdb8b88777f1748d71819dec2177d7d77ad Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:01:10 +0200 Subject: [PATCH 02/52] [TASK] Add strict types declaration in AjaxRoutes.php --- Configuration/Backend/AjaxRoutes.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Configuration/Backend/AjaxRoutes.php b/Configuration/Backend/AjaxRoutes.php index 9b8251c..b691aea 100644 --- a/Configuration/Backend/AjaxRoutes.php +++ b/Configuration/Backend/AjaxRoutes.php @@ -1,5 +1,7 @@ Date: Mon, 3 Aug 2026 13:01:47 +0200 Subject: [PATCH 03/52] [TASK] Remove redundant union type parentheses in AjaxSolrController --- Classes/Controller/Ajax/AjaxSolrController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Classes/Controller/Ajax/AjaxSolrController.php b/Classes/Controller/Ajax/AjaxSolrController.php index b41997d..e861b35 100644 --- a/Classes/Controller/Ajax/AjaxSolrController.php +++ b/Classes/Controller/Ajax/AjaxSolrController.php @@ -88,7 +88,7 @@ public function createIndexQueueAction(ServerRequest $request): ResponseInterfac $site, $indexingConfigurationName, ); - } catch (ConnectionException|Exception $e) { + } catch (ConnectionException|Exception) { } } From 07c064871b62657411013f93468ce0e076ae9c69 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:02:10 +0200 Subject: [PATCH 04/52] [TASK] Remove redundant variable name in exception catch block --- Classes/Command/CacheQueryCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Classes/Command/CacheQueryCommand.php b/Classes/Command/CacheQueryCommand.php index c1133cd..edafb64 100644 --- a/Classes/Command/CacheQueryCommand.php +++ b/Classes/Command/CacheQueryCommand.php @@ -116,7 +116,7 @@ protected function getCache(string $cacheIdentifier): ?FrontendInterface { try { $cache = $this->getCacheManager()->getCache($cacheIdentifier); - } catch (NoSuchCacheException $noSuchCacheException) { + } catch (NoSuchCacheException) { return null; } From 02f5aa3af5d3b800a4239bf498a25873bffed967 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:04:53 +0200 Subject: [PATCH 05/52] [TASK] Add readonly constructor and improve variable handling in CachingFrameworkLoggerHook - Introduce readonly `ConnectionPool` via constructor. - Use `JSON_THROW_ON_ERROR` for safer JSON encoding. - Replace `mb_strpos` with `str_contains` for cleaner string matching. - Refactor `getConnectionPool` to use the readonly property. --- Classes/Hooks/CachingFrameworkLoggerHook.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Classes/Hooks/CachingFrameworkLoggerHook.php b/Classes/Hooks/CachingFrameworkLoggerHook.php index cef9801..097e84e 100644 --- a/Classes/Hooks/CachingFrameworkLoggerHook.php +++ b/Classes/Hooks/CachingFrameworkLoggerHook.php @@ -28,6 +28,9 @@ class CachingFrameworkLoggerHook implements LoggerAwareInterface use LoggerAwareTrait; use RequestArgumentsTrait; + public function __construct(private readonly ConnectionPool $connectionPool) + {} + /** * Analyze the data. If it matches create a new log entry * @@ -57,7 +60,7 @@ public function analyze(array $parameters, VariableFrontend $frontend): void // I know nothing about the datatype, structure or whatever in $variable. // IMO a string representation is a good start for analyzing: preg_match, strpos, ... if (!is_string($variable)) { - $variable = json_encode($variable); + $variable = json_encode($variable, JSON_THROW_ON_ERROR); } $matchingExpressionRecords = $this->getExpressionRecordsMatchingVariable($variable, $cacheExpressionRecords); @@ -110,7 +113,7 @@ protected function isVariableMatchingExpressionRecord(string $variable, array $c if (preg_match('/' . preg_quote($cacheExpressionRecord['expression'], '/') . '/', $variable)) { return true; } - } elseif (mb_strpos($variable, $cacheExpressionRecord['expression']) !== false) { + } elseif (str_contains($variable, $cacheExpressionRecord['expression'])) { return true; } @@ -165,6 +168,6 @@ protected function getCacheExpressionRecords(): array protected function getConnectionPool(): ConnectionPool { - return GeneralUtility::makeInstance(ConnectionPool::class); + return $this->connectionPool; } } From 665bb6be7951c4347affae9a2e7ff8f8394ead47 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:05:36 +0200 Subject: [PATCH 06/52] [TASK] Refactor password hashing command for type safety and clarity - Replace `fetch()` with `fetchAssociative()` for consistency. - Remove redundant variable name in exception catch block. - Ensure `substr()` operates on string type for better type handling. --- Classes/Command/ConvertPlainPasswordToHashCommand.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Classes/Command/ConvertPlainPasswordToHashCommand.php b/Classes/Command/ConvertPlainPasswordToHashCommand.php index d053f81..556cbab 100644 --- a/Classes/Command/ConvertPlainPasswordToHashCommand.php +++ b/Classes/Command/ConvertPlainPasswordToHashCommand.php @@ -78,7 +78,7 @@ protected function updateUsers(string $mode): void $counter = 0; $connection = $this->getConnectionPool()->getConnectionForTable($this->modeMapping[$mode]['table']); $statement = $this->getQueryResultForUsers($this->modeMapping[$mode]['table']); - while ($user = $statement->fetch()) { + while ($user = $statement->fetchAssociative()) { if (!isset($user['password']) || $user['password'] === '') { continue; } @@ -93,7 +93,7 @@ protected function updateUsers(string $mode): void 'Password for User ' . $user['uid'] . ' was not updated. Password already hashed.', OutputInterface::VERBOSITY_VERBOSE, ); - } catch (InvalidPasswordHashException $e) { + } catch (InvalidPasswordHashException) { // Perfect. No HashInstance can process this user password. Start update $connection->update( $this->modeMapping[$mode]['table'], @@ -134,7 +134,7 @@ protected function getNewHashedPassword(string $password, string $mode): string OutputInterface::VERBOSITY_VERBOSE, ); $this->output->writeln( - '--> Hashed password will be stored (Hash shortened): ' . substr($newPassword, 0, 10), + '--> Hashed password will be stored (Hash shortened): ' . substr((string) $newPassword, 0, 10), OutputInterface::VERBOSITY_DEBUG, ); return $newPassword; From b872c068a83a7453aab6a2621de11fe470bf5423 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:06:57 +0200 Subject: [PATCH 07/52] [TASK] Add readonly properties for FlashMessageService and ConnectionPool in ExecuteQueryTask - Introduce readonly properties via constructor for dependency injection. - Replace `GeneralUtility::makeInstance()` calls with readonly properties for better type safety. --- Classes/Task/ExecuteQueryTask.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Classes/Task/ExecuteQueryTask.php b/Classes/Task/ExecuteQueryTask.php index 3e83b4b..8fcc31d 100644 --- a/Classes/Task/ExecuteQueryTask.php +++ b/Classes/Task/ExecuteQueryTask.php @@ -25,6 +25,14 @@ class ExecuteQueryTask extends AbstractTask { protected string $sqlQuery = ''; + public function __construct( + private readonly FlashMessageService $flashMessageService, + private readonly ConnectionPool $connectionPool + ) + { + parent::__construct(); + } + public function execute(): bool { try { @@ -80,13 +88,13 @@ public function setSqlQuery(string $sqlQuery): void public function addMessage(string $message, ContextualFeedbackSeverity $severity = ContextualFeedbackSeverity::OK): void { $flashMessage = GeneralUtility::makeInstance(FlashMessage::class, $message, '', $severity); - $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); + $flashMessageService = $this->flashMessageService; $defaultFlashMessageQueue = $flashMessageService->getMessageQueueByIdentifier(); $defaultFlashMessageQueue->enqueue($flashMessage); } protected function getConnectionPool(): ConnectionPool { - return GeneralUtility::makeInstance(ConnectionPool::class); + return $this->connectionPool; } } From 97aeb76738689dd0e44529a16cb906ef6ddd3e20 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:11:25 +0200 Subject: [PATCH 08/52] [TASK] Declare strict types and update TYPO3 dependency constraint --- ext_emconf.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ext_emconf.php b/ext_emconf.php index c8a0a11..9fa1230 100644 --- a/ext_emconf.php +++ b/ext_emconf.php @@ -1,12 +1,13 @@ 'JW tools', 'description' => 'Jwtools2 contains a scheduler task for Solr to index multiple Pagetrees and a task to execute SQL-Queries. Further there are settings to enable some features in TYPO3 like showing the Page UID in Pagetree with a simple click in extensionmanager.', @@ -18,7 +19,7 @@ 'version' => '8.1.1', 'constraints' => [ 'depends' => [ - 'typo3' => '13.4.0-13.4.99', + 'typo3' => '13.4.24-13.4.99', ], 'conflicts' => [ ], From ec0aeecdbf1ebf8868d56fb932454e3b84481e09 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:13:55 +0200 Subject: [PATCH 09/52] [TASK] Declare strict types and add ValidateFileMetaDataOnSaveHook - Add `declare(strict_types=1)` to `ext_localconf.php` for better type safety. - Register `ValidateFileMetaDataOnSaveHook` if configuration allows. - Simplify existing conditional configurations for clarity and consistency. --- ext_localconf.php | 108 ++++++++++++++++++++++------------------------ 1 file changed, 51 insertions(+), 57 deletions(-) diff --git a/ext_localconf.php b/ext_localconf.php index db67102..ce452bd 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -1,5 +1,7 @@ get('jwtools2'); - - // Create our own logger file - if (!isset($GLOBALS['TYPO3_CONF_VARS']['LOG']['JWeiland']['Jwtools2']['writerConfiguration'])) { - $GLOBALS['TYPO3_CONF_VARS']['LOG']['JWeiland']['Jwtools2']['writerConfiguration'] = [ - LogLevel::INFO => [ - FileWriter::class => [ - 'logFileInfix' => 'jwtools2', - ], +$jwToolsConfiguration = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('jwtools2'); +// Logger File +if (!isset($GLOBALS['TYPO3_CONF_VARS']['LOG']['JWeiland']['Jwtools2']['writerConfiguration'])) { + $GLOBALS['TYPO3_CONF_VARS']['LOG']['JWeiland']['Jwtools2']['writerConfiguration'] = [ + LogLevel::INFO => [ + FileWriter::class => [ + 'logFileInfix' => 'jwtools2', ], - ]; - } + ], + ]; +} - if ($jwToolsConfiguration['solrEnable'] ?? false) { - // Add scheduler task to index all Solr Sites - $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][IndexQueueWorkerTask::class] = [ - 'extension' => 'jwtools2', - 'title' => 'LLL:EXT:jwtools2/Resources/Private/Language/locallang.xlf:indexqueueworker_title', - 'description' => 'LLL:EXT:jwtools2/Resources/Private/Language/locallang.xlf:indexqueueworker_description', - 'additionalFields' => IndexQueueWorkerTaskAdditionalFieldProvider::class, - ]; - } +if ($jwToolsConfiguration['solrEnable'] ?? false) { + // Add scheduler task to index all Solr Sites + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][IndexQueueWorkerTask::class] = [ + 'extension' => 'jwtools2', + 'title' => 'LLL:EXT:jwtools2/Resources/Private/Language/locallang.xlf:indexqueueworker_title', + 'description' => 'LLL:EXT:jwtools2/Resources/Private/Language/locallang.xlf:indexqueueworker_description', + 'additionalFields' => IndexQueueWorkerTaskAdditionalFieldProvider::class, + ]; +} - if ($jwToolsConfiguration['enableSqlQueryTask'] ?? false) { - // Add scheduler task to execute SQL-Queries - $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][ExecuteQueryTask::class] = [ - 'extension' => 'jwtools2', - 'title' => 'LLL:EXT:jwtools2/Resources/Private/Language/locallang.xlf:executeQueryTask.title', - 'description' => 'LLL:EXT:jwtools2/Resources/Private/Language/locallang.xlf:executeQueryTask.description', - 'additionalFields' => ExecuteQueryTaskAdditionalFieldProvider::class, - ]; - } +if ($jwToolsConfiguration['enableSqlQueryTask'] ?? false) { + // Add scheduler task to execute SQL-Queries + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][ExecuteQueryTask::class] = [ + 'extension' => 'jwtools2', + 'title' => 'LLL:EXT:jwtools2/Resources/Private/Language/locallang.xlf:executeQueryTask.title', + 'description' => 'LLL:EXT:jwtools2/Resources/Private/Language/locallang.xlf:executeQueryTask.description', + 'additionalFields' => ExecuteQueryTaskAdditionalFieldProvider::class, + ]; +} - if ($jwToolsConfiguration['typo3EnableUidInPageTree'] ?? false) { - ExtensionManagementUtility::addUserTSConfig( - 'options.pageTree.showPageIdWithTitle = 1', - ); - } +if ($jwToolsConfiguration['typo3ExcludeVideoFilesFromFalFilter'] ?? false) { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['fal']['defaultFilterCallbacks'] = [ + FileNameFilter::filterHiddenFilesAndFolders(...), + ]; +} - if ($jwToolsConfiguration['typo3ExcludeVideoFilesFromFalFilter'] ?? false) { - // Exclude .youtube and .vimeo from hidden files in filelist - $GLOBALS['TYPO3_CONF_VARS']['SYS']['fal']['defaultFilterCallbacks'] = [ - [ - FileNameFilter::class, - 'filterHiddenFilesAndFolders', - ], - ]; - } +if ($jwToolsConfiguration['typo3ApplyFixForMoveTranslatedContentElements'] ?? false) { + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass']['jwtools2MoveTranslated'] + = MoveTranslatedContentElementsHook::class; +} - if ($jwToolsConfiguration['typo3ApplyFixForMoveTranslatedContentElements'] ?? false) { - $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass']['jwtools2MoveTranslated'] - = MoveTranslatedContentElementsHook::class; - } +if ($jwToolsConfiguration['enableCachingFrameworkLogger'] ?? false) { + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/cache/frontend/class.t3lib_cache_frontend_variablefrontend.php']['set'][1655965501] + = CachingFrameworkLoggerHook::class . '->analyze'; +} - if ($jwToolsConfiguration['enableCachingFrameworkLogger'] ?? false) { - $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/cache/frontend/class.t3lib_cache_frontend_variablefrontend.php']['set'][1655965501] - = CachingFrameworkLoggerHook::class . '->analyze'; - } +if ($jwToolsConfiguration['typo3PreventSavingContentWithInvalidFileMetaData'] ?? false) { + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['jwtools2ValidateFileMetaData'] + = ValidateFileMetaDataOnSaveHook::class; +} - // Register an Aspect to store source/target-mapping. Will be activated, if used in SiteConfiguration only. - $GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['aspects']['PersistedTableMapper'] - = PersistedTableMapper::class; -}); +// Register an Aspect to store source/target-mapping. Will be activated, if used in SiteConfiguration only. +$GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['aspects']['PersistedTableMapper'] + = PersistedTableMapper::class; From e7f4ee691e9657e8a9113ccc801fbb1d6b8f134b Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:14:29 +0200 Subject: [PATCH 10/52] [TASK] Add strict types declaration in Icons.php --- Configuration/Icons.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Configuration/Icons.php b/Configuration/Icons.php index 64a696e..7f9bf15 100644 --- a/Configuration/Icons.php +++ b/Configuration/Icons.php @@ -1,5 +1,7 @@ Date: Mon, 3 Aug 2026 13:17:53 +0200 Subject: [PATCH 11/52] [TASK] Introduce readonly Registry property in IndexQueueWorkerTask - Add readonly `Registry` property via constructor for dependency injection. - Replace `getRegistry()` method calls with `this->registry`. - Remove redundant `getRegistry()` method for cleaner code. --- Classes/Task/IndexQueueWorkerTask.php | 28 +++++++++++++-------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/Classes/Task/IndexQueueWorkerTask.php b/Classes/Task/IndexQueueWorkerTask.php index 37d9c5b..bd906c2 100644 --- a/Classes/Task/IndexQueueWorkerTask.php +++ b/Classes/Task/IndexQueueWorkerTask.php @@ -32,15 +32,19 @@ class IndexQueueWorkerTask extends AbstractTask implements ProgressProviderInter protected int $maxSitesPerRun = 10; + public function __construct(private readonly Registry $registry) + { + parent::__construct(); + } + /** * Works through the indexing queue and indexes the queued items into Solr. */ public function execute(): bool { - $registry = $this->getRegistry(); - $registry->set('jwtools2-solr', 'memoryPeakUsage', 0); + $this->registry->set('jwtools2-solr', 'memoryPeakUsage', 0); - $lastSitePosition = (int)$registry->get('jwtools2-solr', 'lastSitePosition'); + $lastSitePosition = (int)$this->registry->get('jwtools2-solr', 'lastSitePosition'); $maxSitePosition = $lastSitePosition + $this->getMaxSitesPerRun(); $cliEnvironment = null; @@ -61,19 +65,19 @@ public function execute(): bool continue; } - $registry->set('jwtools2-solr', 'rootPageId', $availableSite->getRootPageId()); + $this->registry->set('jwtools2-solr', 'rootPageId', $availableSite->getRootPageId()); try { $indexService = GeneralUtility::makeInstance(IndexService::class, $availableSite); $indexService->indexItems($this->documentsToIndexLimit); $counter++; - } catch (\Exception $e) { + } catch (\Exception) { // jump to next site continue; } } - $registry->set( + $this->registry->set( 'jwtools2-solr', 'lastSitePosition', $maxSitePosition > count($availableSites) ? 0 : $maxSitePosition, @@ -92,8 +96,7 @@ public function execute(): bool */ public function getAdditionalInformation(): string { - $registry = $this->getRegistry(); - $rootPageId = (int)$registry->get('jwtools2-solr', 'rootPageId'); + $rootPageId = (int)$this->registry->get('jwtools2-solr', 'rootPageId'); $message = 'Please execute this task first to retrieve site information'; if ($rootPageId === 0) { return $message; @@ -113,8 +116,8 @@ public function getAdditionalInformation(): string $message .= ' Failures: ' . $failedItemsCount; } - $message .= ' / Index queue UID: ' . $registry->get('jwtools2-solr', 'indexQueueUid'); - $message .= ' / Memory Peak: ' . (float)$registry->get('jwtools2-solr', 'memoryPeakUsage'); + $message .= ' / Index queue UID: ' . $this->registry->get('jwtools2-solr', 'indexQueueUid'); + $message .= ' / Memory Peak: ' . (float)$this->registry->get('jwtools2-solr', 'memoryPeakUsage'); } return $message; @@ -170,9 +173,4 @@ public function setMaxSitesPerRun(int $maxSitesPerRun): void { $this->maxSitesPerRun = $maxSitesPerRun; } - - protected function getRegistry(): Registry - { - return GeneralUtility::makeInstance(Registry::class); - } } From e0d5a81885d982c7898d34add639a688853ab840 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:22:09 +0200 Subject: [PATCH 12/52] [TASK] Add readonly Registry property in IndexService - Introduce readonly `Registry` property via constructor for dependency injection. - Replace `GeneralUtility::makeInstance()` calls with `this->registry` for improved type safety. --- Classes/Hooks/IndexService.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/Classes/Hooks/IndexService.php b/Classes/Hooks/IndexService.php index e82a25c..11f52fb 100644 --- a/Classes/Hooks/IndexService.php +++ b/Classes/Hooks/IndexService.php @@ -14,13 +14,12 @@ use ApacheSolrForTypo3\Solr\IndexQueue\Item; use ApacheSolrForTypo3\Solr\Task\IndexQueueWorkerTask; use TYPO3\CMS\Core\Registry; -use TYPO3\CMS\Core\Utility\GeneralUtility; -/** - * Class IndexService - */ -class IndexService +readonly class IndexService { + public function __construct(private Registry $registry) + {} + /** * Save current Item ID in sys_registry for debugging * @@ -29,8 +28,7 @@ class IndexService */ public function beforeIndexItem(Item $item, ?IndexQueueWorkerTask $task, string $uniqueId = ''): void { - $registry = GeneralUtility::makeInstance(Registry::class); - $registry->set('jwtools2-solr', 'indexQueueUid', $item->getIndexQueueUid()); - $registry->set('jwtools2-solr', 'memoryPeakUsage', memory_get_peak_usage(true)); + $this->registry->set('jwtools2-solr', 'indexQueueUid', $item->getIndexQueueUid()); + $this->registry->set('jwtools2-solr', 'memoryPeakUsage', memory_get_peak_usage(true)); } } From 4a7d1706c1f35f97a12dc3f81d145734b3b9a79b Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:26:24 +0200 Subject: [PATCH 13/52] [TASK] Add readonly Registry property in IndexServiceEventListener --- Classes/EventListener/IndexServiceEventListener.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Classes/EventListener/IndexServiceEventListener.php b/Classes/EventListener/IndexServiceEventListener.php index 93c3e14..f62c26f 100644 --- a/Classes/EventListener/IndexServiceEventListener.php +++ b/Classes/EventListener/IndexServiceEventListener.php @@ -13,16 +13,17 @@ use ApacheSolrForTypo3\Solr\Event\Indexing\BeforeItemIsIndexedEvent; use TYPO3\CMS\Core\Registry; -use TYPO3\CMS\Core\Utility\GeneralUtility; -class IndexServiceEventListener +readonly class IndexServiceEventListener { + public function __construct(private Registry $registry) + {} + public function __invoke(BeforeItemIsIndexedEvent $event): void { $item = $event->getItem(); - $registry = GeneralUtility::makeInstance(Registry::class); - $registry->set('jwtools2-solr', 'indexQueueUid', $item->getIndexQueueUid()); - $registry->set('jwtools2-solr', 'memoryPeakUsage', memory_get_peak_usage(true)); + $this->registry->set('jwtools2-solr', 'indexQueueUid', $item->getIndexQueueUid()); + $this->registry->set('jwtools2-solr', 'memoryPeakUsage', memory_get_peak_usage(true)); } } From 09642e70d196b0002b5270eda1a65ffb02e058f1 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:31:04 +0200 Subject: [PATCH 14/52] [TASK] Refactor IsFileSelectableEventListener for dependency injection and improved file metadata validation - Introduce readonly `FileMetaDataValidationService` and `FlashMessageService` properties via constructor. - Replace extension configuration-based column validation with `FileMetaDataValidationService`. - Simplify logic for checking file validity and required metadata columns. - Remove redundant methods for cleaner and more maintainable code. --- .../IsFileSelectableEventListener.php | 117 ++++-------------- 1 file changed, 22 insertions(+), 95 deletions(-) diff --git a/Classes/EventListener/IsFileSelectableEventListener.php b/Classes/EventListener/IsFileSelectableEventListener.php index deebe41..b070b38 100644 --- a/Classes/EventListener/IsFileSelectableEventListener.php +++ b/Classes/EventListener/IsFileSelectableEventListener.php @@ -11,12 +11,9 @@ namespace JWeiland\Jwtools2\EventListener; +use JWeiland\Jwtools2\Service\FileMetaDataValidationService; use TYPO3\CMS\Backend\ElementBrowser\Event\IsFileSelectableEvent; use TYPO3\CMS\Backend\Utility\BackendUtility; -use TYPO3\CMS\Core\Configuration\Exception\ExtensionConfigurationExtensionNotConfiguredException; -use TYPO3\CMS\Core\Configuration\Exception\ExtensionConfigurationPathDoesNotExistException; -use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; -use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Localization\LanguageService; use TYPO3\CMS\Core\Messaging\FlashMessage; use TYPO3\CMS\Core\Messaging\FlashMessageQueue; @@ -29,47 +26,36 @@ /** * Reduce category tree to categories of PIDs within current page tree */ -final class IsFileSelectableEventListener +final readonly class IsFileSelectableEventListener { - public function __construct(protected readonly ExtensionConfiguration $extensionConfiguration) {} + public function __construct( + private FileMetaDataValidationService $fileMetaDataValidationService, + private FlashMessageService $flashMessageService, + ) {} public function __invoke(IsFileSelectableEvent $event): void { - if ($requiredColumns = $this->getRequiredColumnsFromExtensionConfiguration()) { - $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); - $flashMessageQueue = $flashMessageService->getMessageQueueByIdentifier(); - $message = $this->getFlashMessageDescription($requiredColumns); + $requiredColumns = $this->fileMetaDataValidationService->getRequiredColumns(); - if (!$this->checkMessageExists($flashMessageQueue, $message)) { - $this->addFlashMessage($flashMessageQueue, $message); - } - - if (!$event->getFile() instanceof File) { - // Do not process folders or processed files - return; - } - - if ($event->getFile()->getType() !== 2) { - // Process only images - return; - } + if ($requiredColumns === []) { + return; + } - foreach ($this->getRequiredColumnsForFileMetaData() as $requiredColumn) { - $properties = $event->getFile()->getProperties(); + $flashMessageService = $this->flashMessageService; + $flashMessageQueue = $flashMessageService->getMessageQueueByIdentifier(); + $message = $this->getFlashMessageDescription($requiredColumns); - // Do not use isset() as "null" values have to be tested, too. - if (!array_key_exists($requiredColumn, $properties)) { - $event->denyFileSelection(); - } + if (!$this->checkMessageExists($flashMessageQueue, $message)) { + $this->addFlashMessage($flashMessageQueue, $message); + } - $value = is_string($properties[$requiredColumn]) - ? trim($properties[$requiredColumn]) - : $properties[$requiredColumn]; + if (!$event->getFile() instanceof File) { + // Do not process folders or processed files + return; + } - if (!isset($value) || trim($value) === null || trim($value) === '') { - $event->denyFileSelection(); - } - } + if ($this->fileMetaDataValidationService->getMissingColumns($event->getFile()) !== []) { + $event->denyFileSelection(); } } @@ -130,60 +116,6 @@ protected function getTranslatedColumnNames(array $requiredColumns): array return $requiredColumns; } - protected function getRequiredColumnsForFileMetaData(): array - { - // Cache result, is this method will be called from within a loop - static $requiredColumns = null; - - if ($requiredColumns === null) { - $validColumns = []; - foreach ($this->getRequiredColumnsFromExtensionConfiguration() as $column) { - if ($this->isValidColumn($column)) { - $validColumns[] = $column; - } - } - - $requiredColumns = $validColumns; - } - - return $requiredColumns; - } - - protected function getRequiredColumnsFromExtensionConfiguration(): array - { - static $requiredColumns = null; - - if ($requiredColumns === null) { - try { - $requiredColumns = GeneralUtility::trimExplode( - ',', - $this->extensionConfiguration->get('jwtools2', 'typo3RequiredColumnsForFiles'), - true, - ); - } catch (ExtensionConfigurationExtensionNotConfiguredException|ExtensionConfigurationPathDoesNotExistException $exception) { - } - } - - return $requiredColumns; - } - - protected function isValidColumn(string $column, string $table = 'sys_file'): bool - { - $columnExists = false; - $connection = $this->getConnectionPool()->getConnectionForTable($table); - $schemaManager = $connection->createSchemaManager(); - if ( - $schemaManager->tablesExist([$table]) - ) { - $columnExists = $schemaManager->introspectTable($table)->hasColumn($column); - if ($columnExists === false && $table !== 'sys_file_metadata') { - $columnExists = $this->isValidColumn($column, 'sys_file_metadata'); - } - } - - return $columnExists; - } - protected function checkMessageExists(FlashMessageQueue $flashMessageQueue, string $message): bool { $messageExists = false; @@ -201,9 +133,4 @@ protected function getLanguageService(): LanguageService { return $GLOBALS['LANG']; } - - protected function getConnectionPool(): ConnectionPool - { - return GeneralUtility::makeInstance(ConnectionPool::class); - } } From d601eddcf6cdcb4795ff90ef87f05d6eb3bb76a6 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:31:27 +0200 Subject: [PATCH 15/52] [TASK] Declare strict types in JavaScriptModules.php for type safety --- Configuration/JavaScriptModules.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Configuration/JavaScriptModules.php b/Configuration/JavaScriptModules.php index 5ecb552..cd53c79 100644 --- a/Configuration/JavaScriptModules.php +++ b/Configuration/JavaScriptModules.php @@ -1,12 +1,13 @@ [ 'backend', 'core', From 18d0714b7b0d2134c18b63c843b9de2c2ab2541d Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:32:53 +0200 Subject: [PATCH 16/52] [TASK] Update language files for improved metadata handling - Add translations for file metadata overview, filters, and actions. - Update XML structure for consistency and better multilingual support. --- Resources/Private/Language/locallang.xlf | 183 ++++++++++++++---- Resources/Private/Language/locallang_mod.xlf | 50 +++-- .../Language/locallang_module_tools.xlf | 35 ++-- 3 files changed, 194 insertions(+), 74 deletions(-) diff --git a/Resources/Private/Language/locallang.xlf b/Resources/Private/Language/locallang.xlf index 860f2ff..d660dcd 100644 --- a/Resources/Private/Language/locallang.xlf +++ b/Resources/Private/Language/locallang.xlf @@ -1,38 +1,147 @@ - - - -
- - - Index Queue Worker - - - Processes the items in the Index Queue and sends them to Solr. - - - Number of documents to index - - - Max sites per run - - - Execute SQL Query - - - With this task you can write your own SQL-Statement which will be executed with each scheduler run - - - SQL Query - - - Module: Solr Overview - - - Current Memory Usage of running jwtools2 Solr Scheduler Task - - - Root Pages with Solr configuration - - - + + + +
+ + + Index Queue Worker + + + Processes the items in the Index Queue and sends them to Solr. + + + Number of documents to index + + + Max sites per run + + + Execute SQL Query + + + With this task you can write your own SQL-Statement which will be executed with each scheduler run + + + SQL Query + + + Module: Solr Overview + + + Current Memory Usage of running jwtools2 Solr Scheduler Task + + + Root Pages with Solr configuration + + + + File metadata overview + + + Lists images, their metadata status and where they are referenced (content elements and other extension records). + + + This filter combination had to be evaluated in PHP and was capped at the first %s matching images. Narrow the filter (e.g. by storage or extension) for a complete result on very large installations. + + + Metadata status + + + All + + + Valid + + + Partially filled + + + Not filled at all + + + Missing column + + + Any + + + Referenced by + + + All + + + Content elements only + + + Other extension records only + + + Content elements and other records + + + Not referenced anywhere + + + Storage + + + File extension + + + Search filename + + + Apply filter + + + %s image(s) found + + + Preview + + + File + + + Status + + + Missing columns + + + Referenced by + + + Actions + + + Valid + + + Partial + + + Missing + + + Not referenced anywhere + + + Edit metadata + + + No images match the current filter. + + + Previous + + + Next + + + Page %s of %s + + + diff --git a/Resources/Private/Language/locallang_mod.xlf b/Resources/Private/Language/locallang_mod.xlf index 83cb543..e741ca8 100644 --- a/Resources/Private/Language/locallang_mod.xlf +++ b/Resources/Private/Language/locallang_mod.xlf @@ -1,23 +1,31 @@ - - - -
- - - File metadata updated - - - Successfully created/updated file metadata and deleted related processed files. - + + + +
+ + + File metadata updated + + + Successfully created/updated file metadata and deleted related processed files. + - - Info about non selectable files - - - For some files, certain mandatory fields (%s) must be filled in. If this is not the case, these files - cannot currently be linked to content elements or other records. Please complete these mandatory fields first. - - - - + + Info about non selectable files + + + For some files, certain mandatory fields (%s) must be filled in. If this is not the case, these files cannot currently be linked to content elements or other records. Please complete these mandatory fields first. + + + + This file can be only activated after the meta data is filled. + + + This record has been hidden automatically because it uses the file "{fileName}" whose required metadata is not filled in. Please complete the metadata and re-enable this record. + + + The file relation to "{fileName}" was automatically re-enabled because its required metadata is now filled in. + + + diff --git a/Resources/Private/Language/locallang_module_tools.xlf b/Resources/Private/Language/locallang_module_tools.xlf index 7f7b922..de9cacf 100644 --- a/Resources/Private/Language/locallang_module_tools.xlf +++ b/Resources/Private/Language/locallang_module_tools.xlf @@ -1,17 +1,20 @@ - - - -
- - - JW Tools - - - JW Tools - - - JW Tools - - - + + + +
+ + + JW Tools + JW Tools + + + JW Tools + JW Tools + + + JW Tools + JW Tools + + + From 10f466e9ff0490c8fd24c34e09c60d14a30b2791 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:33:20 +0200 Subject: [PATCH 17/52] [TASK] Add metadata placeholder image in jwtools2 extension --- Resources/Public/Images/MetaDataPlaceholder.png | Bin 0 -> 2650 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 Resources/Public/Images/MetaDataPlaceholder.png diff --git a/Resources/Public/Images/MetaDataPlaceholder.png b/Resources/Public/Images/MetaDataPlaceholder.png new file mode 100644 index 0000000000000000000000000000000000000000..91649edf9b9a7ca5a3a9fddb73a40df844c31a67 GIT binary patch literal 2650 zcmc(hX*8Sb7ROzc%5mCzLa7>qI@LOgRZ40oVhB}3Ybt5cQ$j0BYf24Ks$yuVmYU}_ zYQD5)5@WSRa41J3G)Q75gpdUHb^GCdI_rM9cipw#z4p7G=lQUo|NcFD?RZ;j#Bm`> zAwE97;}+&7*ZKJVrgCUteqd!ADxtu~CnRrSVq}l`wnA~cnzl9D&cq!NOfpC8pS65B zcFNA<#8}KM{%&R6A6LGJ4OCVJ4VNX>Pqs2UTdG>-@j)G&;&LnJb9Iu)?qc=M zCkvhBaWx`1EtnvQJ@Y{W`*_ag$&>ZaTd)YyrXJ%`@?iGhW=`Z>U`U9d;r1NJ$kxHZ z6JhAc+S+=$4Em%h91S*#dHjDgId}`730OhkziSO?dM)m?V`GlGx3QYq+T@?i;1GE^ zxWCnWQO~apDG-Q!++SKoz-1d)1Bxe*Yon~tSwYj@J#%g*P*WGQPox{g^gBf9n8Zw| z+2Z2Vxu8V|AYeLA-CNxxMh>2HPQ}-?0)aJyi=Kts7J#o6fSWuUHe#21*r@xU1PCZ$ z$i2}XX9P-E*IKfTic`;)QHBVk-goubyZiW*^ZR9m^!NMXEUCJvIi*h1p!j!&CB+4X z!bOI{(fp?K8TEAv9#hE+WZEak03c38{!ztDB`!h#u zg)~K!)w8n;4TS+GReb1tvgc@+e}!iZgq8gX+H2b!p>x8u?}Tz{bA@hxZ#AibZE75q z{7DR~U@r%Jaey?oQF3sQ0vz-M&)ZQO^>s@wkBOxtp7sB(-p(2(nANP8hD(8%fa4%H zPpF=s`t`|=;BetTnEx;=aDE5}#b#^`!2G`(B9UaC%2RN4d0k!5G$F!{fZ@I$yi9KJ znxBtB#+rE2PSY9%jLk^tCW^|U&~G-lEU}4#*HANCr>&$I<8JO1<<~v z89@|ZeXg&s*YF~Vii(bojy_9DqSN*^iT-V-`04}J!iWaxlBuPorKY5elvIm9wR2|& zV{F_N!KRFljg67XQt>9TnD0>o+1g&#DozzQnB6CA1dP^4Yz$;{gVFx}>XOet{$5jw zY>0&yN+?;omc4!Z_RSl`%&Ysn&B0RpG6G?r*$e&UDICZ|B)%Atj6K+6r#J^d8lglz6dMDIrsj7=FaP?S1YV zgTc_&)^=+Pt*fX&Gf26amqJ#))%lE9$$`zx%#xr6*zfI8`eEBK@WSowZ32Oi?iyO! z*tpXjf5K}p&lio(iAY&kB2MLuKuICf5#y7Sync;Apyjo(r^2SOTwgo85kS$wTwQjD zAk^N;DLp;iCNC1m?t8}*-QZzwWo2c6?jb=z1BA|^q9OpK!oos~UY@z4j*gD(qq&rX zgoMt{&R*Ft70Kr$!l&oe;U6XL`S`2>SK;w^Sy@>d8yky@i_Oi=KzLkAOH^F^fU{I> zM~6yCSSX6r)zwk`8-Z%Im1 zHDZaIST(X?bwGQ%qPn^okfN%}8mApG!{TOzUui{8)KaNbIq)H6OeV9x>)2Dc#!(eG zJ}*U952J2i5V^77d8QTM;OeEP*{W$*+BQkLOJkK`v^1xpVehk|--wY1-QC@hwV$ji z^iC@&G2FZ``@4+6e1mB+IgGc%1m08YdgsqH8coAG@EuGh_c!)rfX(gOw-r*}m6m4e zwY9eDN&h`xH{?D?p{c29!DOWBX469eXkT)DT59SXoxaUxCnqNh8rizIWTmIG5AWoa zosqFKn{0q`^Wbp2@;Aq5b943%4*IX_or>HJwgJ$q*c zS@l+P>eVw`E*FQx^?mt*%E3JO?HH~LXi%Fk$Yk*xwl~wHx*h?0bFQ*w}dCA{uC; zfq|^bhN&w=lWRz1w!~6Z!zb3(;04Bcm-ncF_V#va7Y-L23sC-h&7XLy?CYhYtaxn# z2?32@%?}r6;sCC`^_O1|FnTdUq1-wgUepbMLrc{}*~FfDUoy&1z-VZEoFOO@u$u0M zm5`RMC@%*}01H|i1v=`jTe*~(z|`FUHYFqRl@aQ|>gT=+L3?%LD0W%|?g{iek72G}gpOdKe A*#H0l literal 0 HcmV?d00001 From 04d6526f5db240ed0b02135400b1ed9a462eec51 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:37:01 +0200 Subject: [PATCH 18/52] [TASK] Refactor ModifyElementInformationHook for dependency injection and code simplification - Introduce readonly properties for `ResourceFactory`, `RendererRegistry`, and `ConnectionPool` via constructor. - Replace `GeneralUtility::makeInstance()` calls with constructor-injected dependencies. - Simplify exception handling and improve type safety in method calls. - Use `in_array` with strict comparison for better code clarity and robustness. --- .../Hooks/ModifyElementInformationHook.php | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/Classes/Hooks/ModifyElementInformationHook.php b/Classes/Hooks/ModifyElementInformationHook.php index d3379b6..57b61e9 100644 --- a/Classes/Hooks/ModifyElementInformationHook.php +++ b/Classes/Hooks/ModifyElementInformationHook.php @@ -85,12 +85,14 @@ class ModifyElementInformationHook protected IconFactory $iconFactory; - protected UriBuilder $uriBuilder; - protected ViewFactoryInterface $viewFactory; - public function __construct() - { + public function __construct( + private readonly ResourceFactory $resourceFactory, + private readonly RendererRegistry $rendererRegistry, + private readonly ConnectionPool $connectionPool, + protected UriBuilder $uriBuilder + ) { $this->iconFactory = GeneralUtility::makeInstance(IconFactory::class); $this->uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); $this->viewFactory = GeneralUtility::makeInstance(ViewFactoryInterface::class); @@ -123,11 +125,11 @@ protected function init(ServerRequestInterface $request): void // Case: 1 already sys_file uid $this->uid = (int)$input; } else { - $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); + $resourceFactory = $this->resourceFactory; $file = $resourceFactory->getFileObjectFromCombinedIdentifier($input); $this->uid = $file->getUid(); } - } catch (ResourceDoesNotExistException $exception) { + } catch (ResourceDoesNotExistException) { // handle exception if needed $this->uid = 0; } @@ -136,7 +138,7 @@ protected function init(ServerRequestInterface $request): void if (isset($GLOBALS['TCA'][$this->table])) { $this->initDatabaseRecord(); - } elseif ($this->table === '_FILE' || $this->table === '_FOLDER' || $this->table === 'sys_file') { + } elseif (in_array($this->table, ['_FILE', '_FOLDER', 'sys_file'], true)) { $this->initFileOrFolderRecord(); } } @@ -175,7 +177,7 @@ protected function initDatabaseRecord(): void */ protected function initFileOrFolderRecord(): void { - $fileOrFolderObject = GeneralUtility::makeInstance(ResourceFactory::class)->retrieveFileOrFolderObject($this->uid); + $fileOrFolderObject = $this->resourceFactory->retrieveFileOrFolderObject($this->uid); if ($fileOrFolderObject instanceof Folder) { $this->folderObject = $fileOrFolderObject; $this->access = $this->folderObject->checkActionPermission('read'); @@ -189,7 +191,7 @@ protected function initFileOrFolderRecord(): void try { $this->row = BackendUtility::getRecordWSOL($this->table, $fileOrFolderObject->getUid()); - } catch (\Exception $e) { + } catch (\Exception) { $this->row = []; } } @@ -259,9 +261,9 @@ protected function getPreview(ServerRequestInterface $request): array if ($this->fileObject->isMissing()) { $preview['missingFile'] = $this->fileObject->getName(); } else { - $rendererRegistry = GeneralUtility::makeInstance(RendererRegistry::class); + $rendererRegistry = $this->rendererRegistry; $fileRenderer = $rendererRegistry->getRenderer($this->fileObject); - $preview['url'] = $this->fileObject->getPublicUrl(true) ?? ''; + $preview['url'] = $this->fileObject->getPublicUrl() ?? ''; $preview['editUrl'] = ''; if ( @@ -310,7 +312,7 @@ protected function getPropertiesForTable(): array $fieldList = $this->getFieldList($this->table, (int)($this->row['uid'] ?? 0)); foreach ($fieldList as $name) { - $name = trim($name); + $name = trim((string) $name); $uid = $this->row['uid'] ?? 0; if (!isset($GLOBALS['TCA'][$this->table]['columns'][$name])) { @@ -437,7 +439,7 @@ protected function getFieldList(string $table, int $uid): array unset($fieldList[$key]); } } - } catch (\Exception $exception) { + } catch (\Exception) { $fieldList = []; } @@ -607,7 +609,7 @@ protected function makeRef(string $table, File|int|string $ref, ServerRequestInt $selectUid = $ref; } - $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + $queryBuilder = $this->connectionPool ->getQueryBuilderForTable('sys_refindex'); $predicates = [ @@ -697,7 +699,7 @@ protected function makeRefFrom(string $table, int $ref, ServerRequestInterface $ $refFromLines = []; $lang = $this->getLanguageService(); - $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + $queryBuilder = $this->connectionPool ->getQueryBuilderForTable('sys_refindex'); $predicates = [ @@ -771,7 +773,7 @@ protected function makeRefFrom(string $table, int $ref, ServerRequestInterface $ */ protected function transformFileReferenceToRecordReference(array $referenceRecord): array { - $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + $queryBuilder = $this->connectionPool ->getQueryBuilderForTable('sys_file_reference'); $queryBuilder->getRestrictions()->removeAll(); $fileReference = $queryBuilder @@ -813,9 +815,9 @@ protected function getBackendUser(): BackendUserAuthentication return $GLOBALS['BE_USER']; } - protected function getLinkScripts($request) + protected function getLinkScripts($request): string { - $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); + $uriBuilder = $this->uriBuilder; $queryParameters = $request->getQueryParams(); return (string)$uriBuilder->buildUriFromRoute( From 00f93050b04204e6ccd906590fc067cb94d501b7 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:39:14 +0200 Subject: [PATCH 19/52] [TASK] Refactor MoveTranslatedContentElementsHook for dependency injection and code optimization - Introduce readonly `ConnectionPool` property via constructor. - Replace `GeneralUtility::makeInstance()` calls with constructor dependency. - Simplify syntax by improving null and empty checks. --- .../Hooks/MoveTranslatedContentElementsHook.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/Classes/Hooks/MoveTranslatedContentElementsHook.php b/Classes/Hooks/MoveTranslatedContentElementsHook.php index d4709b0..43afbec 100644 --- a/Classes/Hooks/MoveTranslatedContentElementsHook.php +++ b/Classes/Hooks/MoveTranslatedContentElementsHook.php @@ -24,13 +24,16 @@ */ class MoveTranslatedContentElementsHook { + public function __construct(private readonly ConnectionPool $connectionPool) + {} + public function processDatamap_beforeStart(DataHandler $dataHandler): void { // For "move"-cmd both cmdmap and datamap has to be filled if ( isset($dataHandler->cmdmap['tt_content'], $dataHandler->datamap['tt_content']) - && !empty($dataHandler->cmdmap['tt_content']) - && !empty($dataHandler->datamap['tt_content']) + && (isset($dataHandler->cmdmap['tt_content']) && $dataHandler->cmdmap['tt_content'] !== []) + && (isset($dataHandler->datamap['tt_content']) && $dataHandler->datamap['tt_content'] !== []) ) { foreach ($dataHandler->cmdmap['tt_content'] as $uid => $cmdRecordInDefaultLanguage) { // sys_language_uid @@ -96,7 +99,7 @@ public function processCmdmap_postProcess( protected function getOverlayRecords($uid, DataHandler $dataHandler): array { - $queryBuilder = $this->getConnectionPool()->getQueryBuilderForTable('tt_content'); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content'); $queryBuilder->getRestrictions() ->removeAll() ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) @@ -119,9 +122,4 @@ protected function getOverlayRecords($uid, DataHandler $dataHandler): array return $contentRecords; } - - protected function getConnectionPool(): ConnectionPool - { - return GeneralUtility::makeInstance(ConnectionPool::class); - } } From 5503c7995d9c0d11873211dc873f11a396655c7c Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:39:52 +0200 Subject: [PATCH 20/52] [TASK] Simplify exception handling and remove redundant docblock in NextRunViewHelper - Refactor `catch` block to omit unused exception variable. - Remove outdated docblock to align with current coding standards. --- Classes/ViewHelpers/Solr/NextRunViewHelper.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Classes/ViewHelpers/Solr/NextRunViewHelper.php b/Classes/ViewHelpers/Solr/NextRunViewHelper.php index be51499..2e11341 100644 --- a/Classes/ViewHelpers/Solr/NextRunViewHelper.php +++ b/Classes/ViewHelpers/Solr/NextRunViewHelper.php @@ -41,9 +41,6 @@ public function initializeArguments(): void ); } - /** - * Calculate next run for given site - */ public function render(): float { $task = $this->schedulerRepository->findSolrSchedulerTask(); @@ -64,7 +61,7 @@ public function render(): float if (!$currentSite instanceof Site) { return 0; } - } catch (\Exception $exception) { + } catch (\Exception) { return 0; } From 4c21a0ba30657277b68a1226e5b09765a15238be Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:40:06 +0200 Subject: [PATCH 21/52] [TASK] Add FileMetaData overview module link in jwtools2 template - Introduce conditional link for FileMetaData overview module based on extension configuration. --- Resources/Private/Templates/Tools/Overview.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Resources/Private/Templates/Tools/Overview.html b/Resources/Private/Templates/Tools/Overview.html index 7faf4ea..c553900 100644 --- a/Resources/Private/Templates/Tools/Overview.html +++ b/Resources/Private/Templates/Tools/Overview.html @@ -11,6 +11,11 @@

Enabled Modules Please check Extension Configuration of jwtools2, if Solr + + + {f:translate(key: 'fileMetaData.title')} + + From 04a571735542e46851dc351aa7f8efc4dec40572 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:40:59 +0200 Subject: [PATCH 22/52] [TASK] Refactor QueryGenerator for dependency injection - Introduce readonly `ConnectionPool` property via constructor. - Replace `GeneralUtility::makeInstance()` calls with constructor-injected `ConnectionPool`. --- Classes/Database/Query/QueryGenerator.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Classes/Database/Query/QueryGenerator.php b/Classes/Database/Query/QueryGenerator.php index 7cc91e1..f786a81 100644 --- a/Classes/Database/Query/QueryGenerator.php +++ b/Classes/Database/Query/QueryGenerator.php @@ -22,6 +22,10 @@ */ class QueryGenerator { + public function __construct(private readonly ConnectionPool $connectionPool) + { + } + /** * @throws Exception */ @@ -37,7 +41,7 @@ public function getTreeList($id, $depth, $begin = 0, $permClause = ''): string $theList = $begin === 0 ? $id : ''; if ($id && $depth > 0) { - $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); $queryBuilder->select('uid') ->from('pages') From 177069b3efdae973f31faf84b1d0aad0adf2b024 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:42:15 +0200 Subject: [PATCH 23/52] [TASK] Refactor ReduceCategoryTreeToPageTree for dependency injection - Introduce readonly `ConnectionPool` property via constructor. - Replace `GeneralUtility::makeInstance()` calls with constructor-injected dependency. - Remove redundant `getConnectionPool()` method. --- .../ReduceCategoryTreeToPageTree.php | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/Classes/EventListener/ReduceCategoryTreeToPageTree.php b/Classes/EventListener/ReduceCategoryTreeToPageTree.php index 660a812..44883e7 100644 --- a/Classes/EventListener/ReduceCategoryTreeToPageTree.php +++ b/Classes/EventListener/ReduceCategoryTreeToPageTree.php @@ -39,12 +39,10 @@ class ReduceCategoryTreeToPageTree protected string $listOfCategoryUids = ''; - protected ExtensionConfiguration $extensionConfiguration; - - public function __construct(ExtensionConfiguration $extensionConfiguration) - { - $this->extensionConfiguration = $extensionConfiguration; - } + public function __construct( + protected ExtensionConfiguration $extensionConfiguration, + private readonly ConnectionPool $connectionPool, + ) {} public function __invoke(ModifyTreeDataEvent $event): void { @@ -57,7 +55,7 @@ public function __invoke(ModifyTreeDataEvent $event): void ) { $this->removePageTreeForeignCategories($event->getTreeData()); } - } catch (ExtensionConfigurationExtensionNotConfiguredException|ExtensionConfigurationPathDoesNotExistException $exception) { + } catch (ExtensionConfigurationExtensionNotConfiguredException|ExtensionConfigurationPathDoesNotExistException) { } } @@ -120,7 +118,7 @@ protected function getPageUid(): int protected function getListOfAllowedCategoryUids(int $pageUid): string { if ($this->listOfCategoryUids === '') { - $queryBuilder = $this->getConnectionPool()->getQueryBuilderForTable('sys_category'); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category'); $categories = $queryBuilder ->select('uid') ->from('sys_category') @@ -201,9 +199,4 @@ protected function getQueryGenerator(): QueryGenerator { return GeneralUtility::makeInstance(QueryGenerator::class); } - - protected function getConnectionPool(): ConnectionPool - { - return GeneralUtility::makeInstance(ConnectionPool::class); - } } From 0ee0d6fa8525a71b92bccf55b2966ab05d1d3e58 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:46:30 +0200 Subject: [PATCH 24/52] [TASK] Remove unused ConnectionPool dependency from SchedulerRepository - Eliminate redundant `ConnectionPool` property and `getConnectionPool()` method. - Simplify constructor and exception handling by removing unused references. - Refactor code for better maintainability and alignment with coding standards. --- Classes/Domain/Repository/SchedulerRepository.php | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/Classes/Domain/Repository/SchedulerRepository.php b/Classes/Domain/Repository/SchedulerRepository.php index db1bf8c..abc2ef6 100644 --- a/Classes/Domain/Repository/SchedulerRepository.php +++ b/Classes/Domain/Repository/SchedulerRepository.php @@ -15,7 +15,6 @@ use TYPO3\CMS\Core\Configuration\Exception\ExtensionConfigurationExtensionNotConfiguredException; use TYPO3\CMS\Core\Configuration\Exception\ExtensionConfigurationPathDoesNotExistException; use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; -use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository; @@ -27,8 +26,8 @@ class SchedulerRepository { public function __construct( private readonly SchedulerTaskRepository $taskRepository, - private readonly ConnectionPool $connectionPool, ) {} + /** * Get Solr Scheduler Task of this extension */ @@ -42,7 +41,7 @@ public function findSolrSchedulerTask(): ?IndexQueueWorkerTask if (!$task instanceof IndexQueueWorkerTask) { return null; } - } catch (\OutOfBoundsException $outOfBoundsException) { + } catch (\OutOfBoundsException) { return null; } @@ -53,13 +52,8 @@ protected function getExtensionConfiguration(string $path): string { try { return (string)GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('jwtools2', $path); - } catch (ExtensionConfigurationExtensionNotConfiguredException | ExtensionConfigurationPathDoesNotExistException $exception) { + } catch (ExtensionConfigurationExtensionNotConfiguredException | ExtensionConfigurationPathDoesNotExistException) { return ''; } } - - protected function getConnectionPool(): ConnectionPool - { - return GeneralUtility::makeInstance(ConnectionPool::class); - } } From a9d0c0add0ab020056187803470313814a046999 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:47:20 +0200 Subject: [PATCH 25/52] [TASK] Add hooks and event listeners for metadata validation and file processing - Register new hooks for handling translated content elements, caching framework logging, and file metadata validation. - Add event listeners for replacing invalid metadata images and managing records with invalid metadata. --- Configuration/Services.yaml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index 3b868a5..9c546b5 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -35,6 +35,18 @@ services: JWeiland\Jwtools2\Hooks\InitializeStdWrap: public: true + # Called by makeInstance in processCmdmapClass hook of DataHandler + JWeiland\Jwtools2\Hooks\MoveTranslatedContentElementsHook: + public: true + + # Called by makeInstance in the "set" hook of VariableFrontend (Caching Framework) + JWeiland\Jwtools2\Hooks\CachingFrameworkLoggerHook: + public: true + + # Called by makeInstance in processDatamapClass hook of DataHandler + JWeiland\Jwtools2\Hooks\ValidateFileMetaDataOnSaveHook: + public: true + JWeiland\Jwtools2\LinkHandler\FileLinkHandler: public: true @@ -59,6 +71,23 @@ services: identifier: 'jwtools2.events.isFileSelectableEvent' event: TYPO3\CMS\Backend\ElementBrowser\Event\IsFileSelectableEvent + JWeiland\Jwtools2\EventListener\ReplaceInvalidMetaDataImageEventListener: + tags: + - name: event.listener + identifier: 'jwtools2.events.beforeFileProcessingEvent' + event: TYPO3\CMS\Core\Resource\Event\BeforeFileProcessingEvent + + JWeiland\Jwtools2\EventListener\DisableRecordsWithInvalidFileMetaDataEventListener: + tags: + - name: event.listener + identifier: 'jwtools2.events.afterFileMetaDataUpdatedEvent' + event: TYPO3\CMS\Core\Resource\Event\AfterFileMetaDataUpdatedEvent + method: 'onMetaDataUpdated' + - name: event.listener + identifier: 'jwtools2.events.afterFileMetaDataCreatedEvent' + event: TYPO3\CMS\Core\Resource\Event\AfterFileMetaDataCreatedEvent + method: 'onMetaDataCreated' + # Backend Context Menu Provider JWeiland\Jwtools2\ContextMenu\ItemProviders\UpdateFileMetaDataProvider: tags: From 149a9b790277990326f554bb48ba87d5176dbf9e Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:50:59 +0200 Subject: [PATCH 26/52] [TASK] Refactor SolrController for dependency injection - Introduce readonly `ModuleTemplateFactory` and `ConnectionPool` properties via constructor. - Replace `GeneralUtility::makeInstance()` calls with constructor-injected `ConnectionPool`. --- Classes/Controller/SolrController.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Classes/Controller/SolrController.php b/Classes/Controller/SolrController.php index 4f8666d..4d86641 100644 --- a/Classes/Controller/SolrController.php +++ b/Classes/Controller/SolrController.php @@ -23,6 +23,7 @@ use JWeiland\Jwtools2\Traits\InjectSchedulerRepositoryTrait; use JWeiland\Jwtools2\Traits\InjectSolrRepositoryTrait; use Psr\Http\Message\ResponseInterface; +use TYPO3\CMS\Backend\Template\ModuleTemplateFactory; use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; @@ -38,6 +39,13 @@ class SolrController extends AbstractController use InjectRegistryTrait; use InjectPageRendererTrait; + public function __construct( + protected readonly ModuleTemplateFactory $moduleTemplateFactory, + private readonly ConnectionPool $connectionPool + ) { + parent::__construct($moduleTemplateFactory); + } + public function initializeView($view): void { if ($view instanceof TemplateAwareViewInterface) { @@ -206,8 +214,7 @@ public function showClearFullIndexFormAction(): ResponseInterface protected function getIndexQueueItem(int $rootPageUid, string $configurationName, int $recordUid): ?Item { - $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class); - $queryBuilder = $connectionPool->getQueryBuilderForTable('tx_solr_indexqueue_item'); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_solr_indexqueue_item'); $indexQueueItem = $queryBuilder ->select('*') ->from('tx_solr_indexqueue_item') @@ -232,8 +239,7 @@ protected function getIndexQueueItem(int $rootPageUid, string $configurationName return null; } - $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class); - $queryBuilder = $connectionPool->getQueryBuilderForTable($indexQueueItem['item_type']); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($indexQueueItem['item_type']); $tableRecord = $queryBuilder ->select('*') ->from($indexQueueItem['item_type']) From ce8b0d3c05f05309f6ea0839bf520d149cca8d86 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:51:35 +0200 Subject: [PATCH 27/52] [TASK] Simplify exception handling in SolrRepository - Refactor `catch` blocks to omit unused exception variables. - Align exception handling with current coding standards. --- Classes/Domain/Repository/SolrRepository.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Classes/Domain/Repository/SolrRepository.php b/Classes/Domain/Repository/SolrRepository.php index e5c3d6e..810e017 100644 --- a/Classes/Domain/Repository/SolrRepository.php +++ b/Classes/Domain/Repository/SolrRepository.php @@ -30,7 +30,7 @@ public function findAllAvailableSites(bool $stopOnInvalidSite = false): array { try { return GeneralUtility::makeInstance(SiteRepository::class)->getAvailableSites($stopOnInvalidSite); - } catch (DBALDriverException | \Throwable $exception) { + } catch (DBALDriverException | \Throwable) { return []; } } @@ -42,7 +42,7 @@ public function findByRootPage(int $rootPage): ?Site { try { return GeneralUtility::makeInstance(SiteRepository::class)->getSiteByRootPageId($rootPage); - } catch (DBALDriverException $dbalDriverException) { + } catch (DBALDriverException) { return null; } } From cd7610b9893831457c088279ede65f45ee244ebd Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:53:09 +0200 Subject: [PATCH 28/52] [TASK] Refactor SolrService for dependency injection - Introduce readonly `ConnectionPool` property via constructor. - Replace `GeneralUtility::makeInstance()` calls with constructor-injected `ConnectionPool`. - Remove redundant `getConnectionPool()` method for simplified code. --- Classes/Service/SolrService.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/Classes/Service/SolrService.php b/Classes/Service/SolrService.php index b837901..c246a3f 100644 --- a/Classes/Service/SolrService.php +++ b/Classes/Service/SolrService.php @@ -23,13 +23,16 @@ */ class SolrService { + public function __construct(private readonly ConnectionPool $connectionPool) + {} + /** * Instead of the Solr Statistic, this Statistic will return * a statistic over all sites */ public function getStatistic(): QueueStatistic { - $queryBuilder = $this->getConnectionPool()->getQueryBuilderForTable('tx_solr_indexqueue_item'); + $queryBuilder = $this->$this->connectionPool->getQueryBuilderForTable('tx_solr_indexqueue_item'); $statement = $queryBuilder ->selectLiteral('indexed < changed as pending, (errors not like "") as failed, COUNT(*) as count') ->from('tx_solr_indexqueue_item') @@ -94,7 +97,7 @@ public function clearItemTableByType(Site $site, string $type = ''): void } $this - ->getConnectionPool() + ->$this->connectionPool ->getConnectionForTable('tx_solr_indexqueue_item') ->delete( 'tx_solr_indexqueue_item', @@ -118,7 +121,7 @@ public function clearFileTableByType(Site $site, string $type = ''): void } $this - ->getConnectionPool() + ->$this->connectionPool ->getConnectionForTable('tx_solr_indexqueue_file') ->delete( 'tx_solr_indexqueue_file', @@ -143,9 +146,4 @@ public function clearSolrIndexByType(Site $site, $type = ''): void $solrServer->getWriteService()->deleteByQuery('fileReferenceType:' . $tableName); // tx_solr_file } } - - protected function getConnectionPool(): ConnectionPool - { - return GeneralUtility::makeInstance(ConnectionPool::class); - } } From 0d5ee8850251529da712ad13d2e162707120039d Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:53:59 +0200 Subject: [PATCH 29/52] [TASK] Simplify exception message syntax in SplitFileRefViewHelper - Replace `get_class()` with modern `$file::class` syntax for improved readability and consistency. --- Classes/ViewHelpers/SplitFileRefViewHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Classes/ViewHelpers/SplitFileRefViewHelper.php b/Classes/ViewHelpers/SplitFileRefViewHelper.php index 4f5acac..37778af 100644 --- a/Classes/ViewHelpers/SplitFileRefViewHelper.php +++ b/Classes/ViewHelpers/SplitFileRefViewHelper.php @@ -51,7 +51,7 @@ public function render(): string if (!$file instanceof FileInterface && !$file instanceof AbstractFileFolder) { throw new \UnexpectedValueException( - 'Supplied file object type ' . get_class($file) . ' must be FileInterface or AbstractFileFolder.', + 'Supplied file object type ' . $file::class . ' must be FileInterface or AbstractFileFolder.', 1563891998, ); } From 05b1cb9028b43ddcb2608cfb969772f1dbc92d3c Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:54:59 +0200 Subject: [PATCH 30/52] [TASK] Use strict typing and improve JSON decoding in StatusReportCommand - Add `string` type declarations for constants. - Enable `JSON_THROW_ON_ERROR` in `json_decode()` for better error handling. --- Classes/Command/StatusReportCommand.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Classes/Command/StatusReportCommand.php b/Classes/Command/StatusReportCommand.php index 7dbf1cf..7835940 100644 --- a/Classes/Command/StatusReportCommand.php +++ b/Classes/Command/StatusReportCommand.php @@ -29,9 +29,9 @@ */ class StatusReportCommand extends Command { - private const RETURN_YES = 'YES'; + private const string RETURN_YES = 'YES'; - private const RETURN_NO = 'NO'; + private const string RETURN_NO = 'NO'; private SchedulerTaskRepository $taskRepository; @@ -111,7 +111,7 @@ protected function checkDatabaseStatus(): void $response->getBody()->rewind(); $json = $response->getBody()->getContents(); - $result = json_decode($json, true); + $result = json_decode($json, true, 512, JSON_THROW_ON_ERROR); $hasSuggestions = isset($result['suggestions']) && $result['suggestions'] !== '' ? 'YES' From 275e283d7f435a4f780578ffb5fb85293aa66454 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:56:48 +0200 Subject: [PATCH 31/52] [TASK] Refactor SysFileController for constructor property promotion and strict type handling - Use constructor property promotion for `ResourceFactory` and `GraphicalFunctions`. - Add strict type casting for variables in `updateFileMetadataAction`. --- Classes/Controller/Ajax/SysFileController.php | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/Classes/Controller/Ajax/SysFileController.php b/Classes/Controller/Ajax/SysFileController.php index 209d176..d689654 100644 --- a/Classes/Controller/Ajax/SysFileController.php +++ b/Classes/Controller/Ajax/SysFileController.php @@ -31,15 +31,10 @@ */ class SysFileController { - protected ResourceFactory $resourceFactory; - - protected GraphicalFunctions $graphicalFunctions; - - public function __construct(ResourceFactory $resourceFactory, GraphicalFunctions $graphicalFunctions) - { - $this->resourceFactory = $resourceFactory; - $this->graphicalFunctions = $graphicalFunctions; - } + public function __construct( + protected ResourceFactory $resourceFactory, + protected GraphicalFunctions $graphicalFunctions + ) {} public function updateFileMetadataAction(ServerRequestInterface $request): JsonResponse { @@ -116,8 +111,8 @@ protected function getValidatedFiles(ServerRequestInterface $request): array $validatedFiles = []; $files = $request->getQueryParams()['CB']['files'] ?? []; foreach ($files as $hash => $file) { - [$table, $hash] = explode('|', $hash); - if ($table === '_FILE' && $hash === substr(md5($file), 0, 10)) { + [$table, $hash] = explode('|', (string) $hash); + if ($table === '_FILE' && $hash === substr(md5((string) $file), 0, 10)) { $validatedFiles[] = $file; } } @@ -134,7 +129,7 @@ protected function determineImageMagickVersion(): string // A version like 6.9.10-23 $version = ''; if (isset($string) && $string !== '') { - [, $version] = explode('Magick', $string); + [, $version] = explode('Magick', (string) $string); [$version] = explode(' ', trim($version)); [$version] = explode('-', trim($version)); $version = trim($version); From b809f891d4dce88c49727b8cf57969da80338718 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:57:30 +0200 Subject: [PATCH 32/52] [TASK] Add strict typing declaration in tx_jwtools2_cache_expression.php file - Enable `strict_types` mode for improved type safety and consistency. --- Configuration/TCA/tx_jwtools2_cache_expression.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Configuration/TCA/tx_jwtools2_cache_expression.php b/Configuration/TCA/tx_jwtools2_cache_expression.php index 7f7002c..fff207b 100755 --- a/Configuration/TCA/tx_jwtools2_cache_expression.php +++ b/Configuration/TCA/tx_jwtools2_cache_expression.php @@ -1,12 +1,13 @@ [ 'title' => 'Cache Expressions', From 24da3499c6032fb3c594c88750782007e4640dfe Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:57:53 +0200 Subject: [PATCH 33/52] [TASK] Add strict typing in tx_jwtools2_stored_routes.php and fix whitespace inconsistency --- Configuration/TCA/tx_jwtools2_stored_routes.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Configuration/TCA/tx_jwtools2_stored_routes.php b/Configuration/TCA/tx_jwtools2_stored_routes.php index cb404bd..7a08b27 100755 --- a/Configuration/TCA/tx_jwtools2_stored_routes.php +++ b/Configuration/TCA/tx_jwtools2_stored_routes.php @@ -1,5 +1,7 @@ [ '1' => [ 'showitem' => 'sys_language_uid, l10n_parent, source, target, - --div--;LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.tabs.access, + --div--;LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.tabs.access, --palette--;LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.palettes.access;access', ], ], From ac86e27f9221e913b6ac96198ef9ba03dfdeb7b7 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:58:09 +0200 Subject: [PATCH 34/52] [TASK] Simplify composer mode check in UnitTestsBootstrap - Remove unnecessary strict comparison in `$composerMode` assignment. --- Build/phpunit/UnitTestsBootstrap.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Build/phpunit/UnitTestsBootstrap.php b/Build/phpunit/UnitTestsBootstrap.php index ec17508..fac559a 100644 --- a/Build/phpunit/UnitTestsBootstrap.php +++ b/Build/phpunit/UnitTestsBootstrap.php @@ -56,7 +56,7 @@ // We can use the "typo3/cms-composer-installers" constant "TYPO3_COMPOSER_MODE" to determine composer mode. // This should be always true except for TYPO3 mono repository. - $composerMode = defined('TYPO3_COMPOSER_MODE') && TYPO3_COMPOSER_MODE === true; + $composerMode = defined('TYPO3_COMPOSER_MODE') && TYPO3_COMPOSER_MODE; // @todo: Remove else branch when dropping support for v12 $hasConsolidatedHttpEntryPoint = class_exists(CoreHttpApplication::class); From e3cb7c1bef3b36053b530b7e6752cff3568046a0 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:59:25 +0200 Subject: [PATCH 35/52] [TASK] Use FileType enum value for determining image file type --- .../ContextMenu/ItemProviders/UpdateFileMetaDataProvider.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Classes/ContextMenu/ItemProviders/UpdateFileMetaDataProvider.php b/Classes/ContextMenu/ItemProviders/UpdateFileMetaDataProvider.php index d791e81..c112d1e 100644 --- a/Classes/ContextMenu/ItemProviders/UpdateFileMetaDataProvider.php +++ b/Classes/ContextMenu/ItemProviders/UpdateFileMetaDataProvider.php @@ -12,6 +12,7 @@ namespace JWeiland\Jwtools2\ContextMenu\ItemProviders; use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\FileType; use TYPO3\CMS\Filelist\ContextMenu\ItemProviders\FileProvider; /** @@ -59,7 +60,7 @@ protected function canUpdateFile(): bool { if ($this->record instanceof File) { // Do not use $this->record->isImage() as this is also true for SVG and PDF - return $this->record->getType() === $this->record::FILETYPE_IMAGE; + return $this->record->getType() === FileType::IMAGE->value; } return false; From 33fb9eeba535323aa33949f96759adbc94018206 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 13:59:39 +0200 Subject: [PATCH 36/52] [TASK] Add FileMetaDataController configuration and new metadata-related settings - Introduce `FileMetaDataController` with initial `list` action in backend module configuration. - Add new TypoScript settings for handling files with invalid metadata, including placeholder image and automatic record handling. - Update `ext_conf_template.txt` and `ExtConf.xlf` to reflect new functionality. --- Configuration/Backend/Modules.php | 4 + Resources/Private/Language/ExtConf.xlf | 170 ++++++++++++++----------- ext_conf_template.txt | 12 ++ 3 files changed, 109 insertions(+), 77 deletions(-) diff --git a/Configuration/Backend/Modules.php b/Configuration/Backend/Modules.php index 866845e..5f3c3f4 100644 --- a/Configuration/Backend/Modules.php +++ b/Configuration/Backend/Modules.php @@ -9,6 +9,7 @@ * LICENSE file that was distributed with this source code. */ +use JWeiland\Jwtools2\Controller\FileMetaDataController; use JWeiland\Jwtools2\Controller\SolrController; use JWeiland\Jwtools2\Controller\ToolsController; @@ -32,6 +33,9 @@ SolrController::class => [ 'list', 'show', 'showIndexQueue', 'indexOneRecord', 'showClearIndexForm', 'clearIndex', 'showClearFullIndexForm', ], + FileMetaDataController::class => [ + 'list', + ], ], ], ]; diff --git a/Resources/Private/Language/ExtConf.xlf b/Resources/Private/Language/ExtConf.xlf index c2f0b95..59cab03 100644 --- a/Resources/Private/Language/ExtConf.xlf +++ b/Resources/Private/Language/ExtConf.xlf @@ -1,79 +1,95 @@ - - - -
- - - Enable UID in pagetree: Enable this option to show the page UID in front of the title in pagetree. So no - need to hover over the page icon anymore. - - - - Transfer TypoScript current: For each record, found by CONTENT, renderObj - will create a new instance of ContentObjectRenderer. The data property will be filled with the values of the record. The TypoScript - current value will be resetted. Activate this option if you want to transfer the parent current value into the - subproperty of renderObj. - - - - Required columns for image files: Enter here columns of the DB tables sys_file and/or sys_file_metadata - which are to be regarded as mandatory fields. Files for which these columns are not set are available in the - file browser, but cannot be selected. - - - - Exclude hidden video files: If you work with video files, TYPO3 creates .youtube and/or .vimeo files. If - a title could not be fetched, a file like ".youtube" will be created which is handled as hidden file. Instead - of showing all hidden files, you can activate this option, to only show these two hidden video types in - filelist. - - - - Apply patch for #21161: If you move content records from col_pos X to Y the related translated records - will not be moved to new col_pos. Activate this feature to solve that problem. See: - https://forge.typo3.org/issues/21161 - - - - Reduce categories to a page tree: In case of a multi-domain TYPO3 instance it makes sense to reduce - categories in a category-tree to PIDs of the current page-tree. So, after activating this checkbox, you will not see - any categories of foreign page-trees anymore. Admin user will still see everything. - - - - Enable SQL-Query Task: With this task you can realize your own recurring SQL-Queries. - - - Update file metadata: Activates a new context menu item in filelist to create/update the file metadata. - It updates create/edit times and width/height of images. - - - - Enable Caching Framework Logger: Hooks into the TYPO3 caching framework and parses all data using - expression records to be created on the root page (PID: 0). If these match, a log entry is created in - var/log/. - - - - Enable provider for EXT reports: If EXT:reports is installed it will show additional information about - updatable extensions. - - - - Set severity for the report about updatable extensions: If set to "Info" you also have to activate the - checkbox "Always send notification mail" in the reports scheduler task. If set to "Warning" you can leave "Always - send notification mail" unchecked. Please have a look into documentation for more information. - - + + + +
+ + + Enable UID in pagetree: Enable this option to show the page UID in front of the title in pagetree. So no + need to hover over the page icon anymore. + + + + Transfer TypoScript current: For each record, found by CONTENT, a new instance of ContentObjectRenderer + will be created by renderObj. The data property will be filled with the values of the record. The TypoScript + current value will be resetted. Activate this option if you want to transfer the parent current value into the + sub-property of renderObj. + + + + Required columns for image files: Enter here columns of the DB tables sys_file and/or sys_file_metadata which are to be regarded as mandatory fields. Files for which these columns are not set are available in the file browser, but cannot be selected. + + + Enable invalid file relations on save: When activated, creating or updating any record will not allow an image with missing "Required columns for image files" to stay active, regardless of table or field. The offending file relation is disabled (hidden) instead, the rest of the record is saved normally, and a message explaining why is shown in the backend. + + + Enable existing records on invalid file metadata: When activated, if a file already in use gets its metadata edited so that a required column becomes empty, every record referencing that file, regardless of table or field, is automatically hidden, with a backend message explaining why. Once the metadata is completed again, the affected file relations are re-enabled automatically. + + + Replace frontend images with a placeholder: When activated, images with missing "Required columns for image files" are swapped for a placeholder image on the frontend only (backend previews still show the real image, so editors can find and fix it). + + + Placeholder image API URL: URL template used to fetch the placeholder image, with "{width}" and "{height}" replaced by the requested processing dimensions. Leave empty to use the placeholder image shipped with this extension instead of an external API. + + + Placeholder image cache folder: Combined identifier (e.g. "1:/placeholder_images/jwtools2/") of the FAL folder used to cache placeholder images downloaded from the API above, so it is not called on every request. Must not contain a path segment named "_temp_" or "_recycler_" - most TYPO3 installations block direct web access to those via .htaccess/nginx rules, which would make the cached placeholder images themselves return 403 in the frontend. + + + Enable file metadata overview module: Adds a backend module listing images, their metadata status and where they are referenced (content elements and other extension records), with filters for missing metadata. + + + Exclude hidden video files: If you work with video files, TYPO3 creates .youtube and/or .vimeo files. If + a title could not be fetched a file like ".youtube" will be created which is handled as hidden file. Instead + of showing all hidden files you can activate this option, to only show these two hidden video types in + filelist. + + + + Apply patch for #21161: If you move content records from col_pos X to Y the related translated records + will not be moved to new col_pos. Activate this feature to solve that problem. See: + https://forge.typo3.org/issues/21161 + + + + Reduce categories to page tree: In case of a multi-domain TYPO3 instances it makes sense to reduce + categories in category-tree to PIDs of current page-tree. So, after activating this checkbox you will not see + any categories of foreign page-trees anymore. Admin user will still see everything. + + + + Enable SQL-Query Task: With this task you can realize your own recurring SQL-Queries. + + + Update file metadata: Activates a new context menu item in filelist to create/update the file metadata. + It updates create/edit times and width/height of images. + + + + Enable Caching Framework Logger: Hooks into the TYPO3 caching framework and parses all data using + expression records to be created on the root page (PID: 0). If these match, a log entry is created in + var/log/. + + + + Enable provider for EXT reports: If EXT:reports is installed it will show additional information about + updatable extensions. + + + + Set severity for report about updatable extensions: If set to "Info" you also have to activate the + checkbox "Always send notification mail" in reports scheduler task. If set to "Warning" you can leave "Always + send notification mail" unchecked. Please have a look into documentation for more information. + + - - Enable Solr features - - - Solr scheduler task UID: After activating Solr features, you have to create the new Solr task of this - extension in scheduler and insert the UID of this task here. - - - - + + Enable Solr features + + + Solr scheduler task UID: After activating Solr features, you have to create the new Solr task of this + extension in scheduler and insert the UID of this task here. + + + + diff --git a/ext_conf_template.txt b/ext_conf_template.txt index 772c589..2e1f5be 100644 --- a/ext_conf_template.txt +++ b/ext_conf_template.txt @@ -4,6 +4,18 @@ typo3EnableUidInPageTree = 0 typo3TransferTypoScriptCurrent = 0 # cat=typo3; type=string; label=LLL:EXT:jwtools2/Resources/Private/Language/ExtConf.xlf:typo3RequiredColumnsForFiles typo3RequiredColumnsForFiles = +# cat=typo3; type=boolean; label=LLL:EXT:jwtools2/Resources/Private/Language/ExtConf.xlf:typo3PreventSavingContentWithInvalidFileMetaData +typo3PreventSavingContentWithInvalidFileMetaData = 0 +# cat=typo3; type=boolean; label=LLL:EXT:jwtools2/Resources/Private/Language/ExtConf.xlf:typo3DisableRecordsOnInvalidFileMetaData +typo3DisableRecordsOnInvalidFileMetaData = 0 +# cat=typo3; type=boolean; label=LLL:EXT:jwtools2/Resources/Private/Language/ExtConf.xlf:typo3ReplaceFrontendImagesWithInvalidMetaData +typo3ReplaceFrontendImagesWithInvalidMetaData = 0 +# cat=typo3; type=string; label=LLL:EXT:jwtools2/Resources/Private/Language/ExtConf.xlf:typo3PlaceholderImageApiUrl +typo3PlaceholderImageApiUrl = https://placehold.co/{width}x{height}/eeeeee/999999.png?text=Metadata+missing +# cat=typo3; type=string; label=LLL:EXT:jwtools2/Resources/Private/Language/ExtConf.xlf:typo3PlaceholderImageStorageFolder +typo3PlaceholderImageStorageFolder = 1:/placeholder_images/jwtools2/ +# cat=typo3; type=boolean; label=LLL:EXT:jwtools2/Resources/Private/Language/ExtConf.xlf:enableFileMetaDataOverviewModule +enableFileMetaDataOverviewModule = 0 # cat=typo3; type=boolean; label=LLL:EXT:jwtools2/Resources/Private/Language/ExtConf.xlf:typo3ExcludeVideoFilesFromFalFilter typo3ExcludeVideoFilesFromFalFilter = 0 # cat=typo3; type=boolean; label=LLL:EXT:jwtools2/Resources/Private/Language/ExtConf.xlf:typo3ApplyFixForMoveTranslatedContentElements From ad4f314c10bbc71cff49bfb60bb0dcd689180ba9 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 14:00:32 +0200 Subject: [PATCH 37/52] [TASK] Add German translations for metadata-related settings and modules - Introduced extensive German translations for `ExtConf.xlf`, backend module labels, and metadata-related features. - Added localized strings to support improved multilingual functionality. --- Resources/Private/Language/de.ExtConf.xlf | 85 ++++++++ Resources/Private/Language/de.locallang.xlf | 193 ++++++++++++++++++ .../Private/Language/de.locallang_mod.xlf | 38 ++++ .../Language/de.locallang_module_tools.xlf | 20 ++ 4 files changed, 336 insertions(+) create mode 100644 Resources/Private/Language/de.ExtConf.xlf create mode 100644 Resources/Private/Language/de.locallang.xlf create mode 100644 Resources/Private/Language/de.locallang_mod.xlf create mode 100644 Resources/Private/Language/de.locallang_module_tools.xlf diff --git a/Resources/Private/Language/de.ExtConf.xlf b/Resources/Private/Language/de.ExtConf.xlf new file mode 100644 index 0000000..a96067a --- /dev/null +++ b/Resources/Private/Language/de.ExtConf.xlf @@ -0,0 +1,85 @@ + + + +
+ + + Enable UID in pagetree: Enable this option to show the page UID in front of the title in pagetree. So no need to hover over the page icon anymore. + Zeige UID im Seitenbaum: Nach Aktivierung wird die Seiten UID vor dem Seitentitel im Seitenbaum dargestellt. Damit braucht Ihr dann nicht mehr mit der Maus über das kleine Seiten Icon drüberfahren, um an die UID dranzukommen. + + + Transfer TypoScript current: For each record, found by CONTENT, a new instance of ContentObjectRenderer will be created by renderObj. The data property will be filled with the values of the record. The TypoScript current value will be resetted. Activate this option if you want to transfer the parent current value into the sub-property of renderObj. + Übertrage TypoScript current: Für jeden Datensatz, der durch CONTENT gefunden wurde, wird bei der Verarbeitung durch renderObj immer eine neue Instanz vom ContentObjectRenderer erzeugt. Die data-Eigenschaft wird mit den Werten des Datensatzes gefüllt während die current-Eigenschaft geleert wird. Nach Aktivierung dieser Option wird der Wert der Eltern current-Eigenschaft mit in die Kind-Eigenschaft (renderObj) übertragen. + + + Required columns for image files: Enter here columns of the DB tables sys_file and/or sys_file_metadata which are to be regarded as mandatory fields. Files for which these columns are not set are available in the file browser, but cannot be selected. + Benötigte Spalten für Bilddateien: Tragen Sie hier Spalten der DB Tabellen sys_file und/oder sys_file_metadata ein, die als Pflichtfelder anzusehen sind. Dateien, bei denen diese Spalten nicht gesetzt sind stehen im Dateibrowser zwar zur Verfügung, können jedoch nicht ausgewählt werden. + + + Disable invalid file relations on save: When activated, creating or updating any record will not allow an image with missing "Required columns for image files" to stay active, regardless of table or field. The offending file relation is disabled (hidden) instead, the rest of the record is saved normally, and a message explaining why is shown in the backend. + Ungültige Dateirelationen beim Speichern deaktivieren: Bei Aktivierung wird beim Anlegen oder Bearbeiten eines beliebigen Datensatzes ein Bild mit fehlenden "Benötigte Spalten für Bilddateien" nicht aktiv bleiben, unabhängig von Tabelle oder Feld. Die betroffene Dateirelation wird stattdessen deaktiviert (versteckt), der restliche Datensatz wird normal gespeichert und im Backend wird eine erklärende Meldung angezeigt. + + + Disable existing records on invalid file metadata: When activated, if a file already in use gets its metadata edited so that a required column becomes empty, every record referencing that file, regardless of table or field, is automatically hidden, with a backend message explaining why. Once the metadata is completed again, the affected file relations are re-enabled automatically. + Bestehende Datensätze bei ungültigen Metadaten deaktivieren: Bei Aktivierung werden, wenn bei einer bereits verwendeten Datei die Metadaten so bearbeitet werden, dass eine Pflichtspalte leer wird, alle Datensätze, die diese Datei referenzieren, unabhängig von Tabelle oder Feld, automatisch versteckt, inklusive einer erklärenden Meldung im Backend. Sobald die Metadaten wieder vollständig sind, werden die betroffenen Dateirelationen automatisch wieder aktiviert. + + + Replace frontend images with a placeholder: When activated, images with missing "Required columns for image files" are swapped for a placeholder image on the frontend only (backend previews still show the real image, so editors can find and fix it). + Frontend-Bilder durch Platzhalter ersetzen: Bei Aktivierung werden Bilder mit fehlenden "Benötigte Spalten für Bilddateien" nur im Frontend durch ein Platzhalterbild ersetzt (Backend-Vorschauen zeigen weiterhin das echte Bild, damit Redakteure es finden und korrigieren können). + + + Placeholder image API URL: URL template used to fetch the placeholder image, with "{width}" and "{height}" replaced by the requested processing dimensions. Leave empty to use the placeholder image shipped with this extension instead of an external API. + Platzhalterbild API-URL: URL-Vorlage zum Abrufen des Platzhalterbildes, wobei "{width}" und "{height}" durch die angeforderten Verarbeitungsmaße ersetzt werden. Leer lassen, um stattdessen das mit dieser Extension ausgelieferte Platzhalterbild zu verwenden. + + + Placeholder image cache folder: Combined identifier (e.g. "1:/placeholder_images/jwtools2/") of the FAL folder used to cache placeholder images downloaded from the API above, so it is not called on every request. Must not contain a path segment named "_temp_" or "_recycler_" - most TYPO3 installations block direct web access to those via .htaccess/nginx rules, which would make the cached placeholder images themselves return 403 in the frontend. + Platzhalterbild Cache-Ordner: Kombinierter Bezeichner (z.B. "1:/placeholder_images/jwtools2/") des FAL-Ordners, in dem von der obigen API heruntergeladene Platzhalterbilder zwischengespeichert werden, damit die API nicht bei jedem Aufruf kontaktiert werden muss. Darf kein Pfadsegment namens "_temp_" oder "_recycler_" enthalten - die meisten TYPO3-Installationen blockieren den direkten Web-Zugriff darauf per .htaccess/nginx-Regeln, wodurch die zwischengespeicherten Platzhalterbilder selbst im Frontend 403 zurückgeben würden. + + + Enable file metadata overview module: Adds a backend module listing images, their metadata status and where they are referenced (content elements and other extension records), with filters for missing metadata. + Modul zur Übersicht der Datei-Metadaten aktivieren: Fügt ein Backend-Modul hinzu, das Bilder, deren Metadaten-Status und deren Verwendung (Inhaltselemente und andere Extension-Datensätze) auflistet, inklusive Filter für fehlende Metadaten. + + + Exclude hidden video files: If you work with video files, TYPO3 creates .youtube and/or .vimeo files. If a title could not be fetched a file like ".youtube" will be created which is handled as hidden file. Instead of showing all hidden files you can activate this option, to only show these two hidden video types in filelist. + Versteckte Video Dateien ausschließen: TYPO3 erstellt für externe Videos Dateien mit der Endung .youtube und/oder .vimeo. Wenn der Videotitel jedoch nicht generiert werden konnte, dann wird eine Datei wie ".vimeo" angelegt, die im Dateisystem als versteckt markiert werden. Anstatt den Redakteuren alle versteckten Datensätze anzeigen zu lassen, kann diese Option aktiviert werden, die nur diese 2 Videotypen in der Filelist anzeigen lässt. + + + Apply patch for #21161: If you move content records from col_pos X to Y the related translated records will not be moved to new col_pos. Activate this feature to solve that problem. See: https://forge.typo3.org/issues/21161 + Patch für #21161 anwenden: Wenn Inhaltselement von col_pos X nach Y verschoben werden, werden die verknüpften Übersetzungen nicht mit verschoben. Aktiviere diese Option, um das Problem zu beheben. Siehe auch: https://forge.typo3.org/issues/21161 + + + Reduce categories to page tree: In case of a multi-domain TYPO3 instances it makes sense to reduce categories in category-tree to PIDs of current page-tree. So, after activating this checkbox you will not see any categories of foreign page-trees anymore. Admin user will still see everything. + Reduziere Kategorien auf Seitenbaum: Bei Multi-Domain Instanzen macht es evtl. Sinn die Kategorien innerhalb des Kategorie-Baumes auf die Kategorien des aktuellen Seitenbaumes zu reduzieren. Nach Aktivierung seht Ihr die Kategorien von den anderen Seitenbäumen nicht mehr. Der Admin jedoch sieht immer alles. + + + Enable SQL-Query Task: With this task you can realize your own recurring SQL-Queries. + SQL-Abfrage-Task aktivieren: Mit diesem scheduler Task könnt Ihr Eure eigenen SQL-Abfragen wiederkehrend einrichten. + + + Update file metadata: Activates a new context menu item in filelist to create/update the file metadata. It updates create/edit times and width/height of images. + Datei Metadaten aktualisieren: Aktiviert einen neuen Eintrag im Kontextmenü der Dateiliste, um den Matadaten Datensatz zu aktualisieren oder neu anzulegen. Aktualisiert werden Erstell- und Bearbeitungsdatum sowie die Bildabmessungen. + + + Enable Caching Framework Logger: Hooks into the TYPO3 caching framework and parses all data using expression records to be created on the root page (PID: 0). If these match, a log entry is created in var/log/. + Caching Framework Logger aktivieren: Klinkt sich in das TYPO3 Caching Framework ein und analysiert alle Daten anhand von Expression Records, die auf der Root-Seite (PID: 0) zu erstellen sind. Wenn diese übereinstimmen, wird ein Protokolleintrag in var/log/ erzeugt + + + Enable provider for EXT reports: If EXT:reports is installed it will show additional information about updatable extensions. + Aktiviere Status-Provider für EXT reports: Wenn die EXT:reports installiert und aktiviert ist werden zusätzlich Informationen über updatefähige Extensions angezeigt. + + + Set severity for report about updatable extensions: If set to "Info" you also have to activate the checkbox "Always send notification mail" in reports scheduler task. If set to "Warning" you can leave "Always send notification mail" unchecked. Please have a look into documentation for more information. + Setze Schweregrad für Bericht über updatefähige Erweiterungen: Wenn "Info" gewählt wurde dann muss auch die Checkbox "Immer eine Benachrichtigung senden" in der Planeraufgabe der Berichtextension gesetzt sein. Wenn "Warning" ausgewählt wird, kann die Checkbox "Immer eine Benachrichtigung senden" deaktiviert bleiben. Für weitere Informationen schaut mal in die Dokumentation. + + + + Enable Solr features + Aktiviere Solr Features + + + Solr scheduler task UID: After activating Solr features, you have to create the new Solr task of this extension in scheduler and insert the UID of this task here. + Solr scheduler task UID: Nach Aktivierung der Solr Features müssen Sie die neue Solr Aufgabe dieser Extension im Planer anlegen und die UID dieser Aufgabe dann hier eintragen. + + + + diff --git a/Resources/Private/Language/de.locallang.xlf b/Resources/Private/Language/de.locallang.xlf new file mode 100644 index 0000000..e69e441 --- /dev/null +++ b/Resources/Private/Language/de.locallang.xlf @@ -0,0 +1,193 @@ + + + +
+ + + Index Queue Worker + Index Queue Worker + + + Processes the items in the Index Queue and sends them to Solr. + Verarbeitet einen Item in der Index Queue und sendet ihn an den Solr Server. + + + Number of documents to index + Anzahl der zu indexierenden Dokumente + + + Max sites per run + Maximale Anzahl der zu indexierenden Seiten pro Durchlauf + + + Execute SQL Query + SQL Abfrage ausführen + + + With this task you can write your own SQL-Statement which will be executed with each scheduler run + Mit diesem Task kannst Du eine beliebige SQL-Abfrage wiederkehrend ausführen lassen. + + + SQL Query + SQL Abfrage + + + Module: Solr Overview + Modul: Solr Übersicht + + + Current Memory Usage of running jwtools2 Solr Scheduler Task + Aktueller RAM-Verbrauch des Solr Scheduler Tasks der jwtools2 + + + Root Pages with Solr configuration + Root-Seiten mit Solr-Konfiguration + + + + File metadata overview + Übersicht Datei-Metadaten + + + Lists images, their metadata status and where they are referenced (content elements and other extension records). + Listet Bilder, deren Metadaten-Status und deren Verwendung (Inhaltselemente und andere Extension-Datensätze) auf. + + + This filter combination had to be evaluated in PHP and was capped at the first %s matching images. Narrow the filter (e.g. by storage or extension) for a complete result on very large installations. + Diese Filterkombination musste in PHP ausgewertet werden und wurde auf die ersten %s passenden Bilder begrenzt. Schränken Sie den Filter (z.B. nach Storage oder Dateiendung) ein, um bei sehr großen Installationen ein vollständiges Ergebnis zu erhalten. + + + Metadata status + Metadaten-Status + + + All + Alle + + + Valid + Gültig + + + Partially filled + Teilweise ausgefüllt + + + Not filled at all + Gar nicht ausgefüllt + + + Missing column + Fehlende Spalte + + + Any + Beliebig + + + Referenced by + Verwendet von + + + All + Alle + + + Content elements only + Nur Inhaltselemente + + + Other extension records only + Nur andere Extension-Datensätze + + + Content elements and other records + Inhaltselemente und andere Datensätze + + + Not referenced anywhere + Nirgends verwendet + + + Storage + Storage + + + File extension + Dateiendung + + + Search filename + Dateiname suchen + + + Apply filter + Filter anwenden + + + %s image(s) found + %s Bild(er) gefunden + + + Preview + Vorschau + + + File + Datei + + + Status + Status + + + Missing columns + Fehlende Spalten + + + Referenced by + Verwendet von + + + Actions + Aktionen + + + Valid + Gültig + + + Partial + Teilweise + + + Missing + Fehlend + + + Not referenced anywhere + Nirgends verwendet + + + Edit metadata + Metadaten bearbeiten + + + No images match the current filter. + Keine Bilder entsprechen dem aktuellen Filter. + + + Previous + Zurück + + + Next + Weiter + + + Page %s of %s + Seite %s von %s + + + + diff --git a/Resources/Private/Language/de.locallang_mod.xlf b/Resources/Private/Language/de.locallang_mod.xlf new file mode 100644 index 0000000..02deffc --- /dev/null +++ b/Resources/Private/Language/de.locallang_mod.xlf @@ -0,0 +1,38 @@ + + + +
+ + + File metadata updated + Datei Metadaten aktualisiert + + + Successfully created/updated file metadata and deleted related processed files. + Die Metadaten der Datei wurden erstellt/aktualisiert und evtl. verknüpfte temporäre Datei entfernt. + + + + Info about non selectable files + Information über nicht selectierbare Dateien + + + For some files, certain mandatory fields (%s) must be filled in. If this is not the case, these files cannot currently be linked to content elements or other records. Please complete these mandatory fields first. + Für einige Dateien müssen bestimmte Pflichtfelder (%s) ausgefüllt sein. Sollte dies nicht der Fall sein, können diese Dateien derzeit nicht mit Inhaltselementen oder anderen Datensätzen verknüpft werden. Bitte ergänzen Sie zunächst diese Pflichtfelder. + + + + This file can be only activated after the meta data is filled. + Diese Datei kann erst aktiviert werden, wenn die Metadaten ausgefüllt sind. + + + This record has been hidden automatically because it uses the file "{fileName}" whose required metadata is not filled in. Please complete the metadata and re-enable this record. + Dieser Datensatz wurde automatisch versteckt, da er die Datei "{fileName}" verwendet, deren Pflichtmetadaten nicht ausgefüllt sind. Bitte ergänzen Sie die Metadaten und aktivieren Sie diesen Datensatz erneut. + + + The file relation to "{fileName}" was automatically re-enabled because its required metadata is now filled in. + Die Dateirelation zu "{fileName}" wurde automatisch wieder aktiviert, da die Pflichtmetadaten nun ausgefüllt sind. + + + + diff --git a/Resources/Private/Language/de.locallang_module_tools.xlf b/Resources/Private/Language/de.locallang_module_tools.xlf new file mode 100644 index 0000000..de9cacf --- /dev/null +++ b/Resources/Private/Language/de.locallang_module_tools.xlf @@ -0,0 +1,20 @@ + + + +
+ + + JW Tools + JW Tools + + + JW Tools + JW Tools + + + JW Tools + JW Tools + + + + From 2a2ed544ccf655896800f42416e77355dfcef556 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 14:11:51 +0200 Subject: [PATCH 38/52] [TASK] Add EventListener to disable records with invalid file metadata - Introduced `DisableRecordsWithInvalidFileMetaDataEventListener` to handle events for invalid file metadata. - Automatically disables or re-enables referencing records based on metadata validation. - Includes registry-based record tracking and page cache flushing for consistency. --- ...dsWithInvalidFileMetaDataEventListener.php | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 Classes/EventListener/DisableRecordsWithInvalidFileMetaDataEventListener.php diff --git a/Classes/EventListener/DisableRecordsWithInvalidFileMetaDataEventListener.php b/Classes/EventListener/DisableRecordsWithInvalidFileMetaDataEventListener.php new file mode 100644 index 0000000..0642387 --- /dev/null +++ b/Classes/EventListener/DisableRecordsWithInvalidFileMetaDataEventListener.php @@ -0,0 +1,264 @@ +handle($event->getFileUid()); + } + + public function onMetaDataCreated(AfterFileMetaDataCreatedEvent $event): void + { + $this->handle($event->getFileUid()); + } + + private function handle(int $fileUid): void + { + if (!$this->isEnabled()) { + return; + } + + $file = $this->getFile($fileUid); + if (!$file instanceof File) { + return; + } + + if ($this->fileMetaDataValidationService->hasValidMetaData($file)) { + $this->reEnablePreviouslyDisabledRecords($file); + + return; + } + + $this->disableReferencingRecords($file); + } + + private function disableReferencingRecords(File $file): void + { + $disabledRecords = []; + + foreach ($this->fileReferenceResolverService->getFileReferencesForFile($file->getUid()) as $reference) { + $table = $reference['tablenames']; + + if ($this->disableRecord($table, $reference['uid_foreign'], $file)) { + $disabledRecords[] = ['table' => $table, 'uid' => $reference['uid_foreign']]; + } + } + + if ($disabledRecords !== []) { + $this->registry->set( + self::REGISTRY_NAMESPACE, + $this->getRegistryKey($file->getUid()), + $disabledRecords + ); + $this->flushPageCache(); + } + } + + private function reEnablePreviouslyDisabledRecords(File $file): void + { + $disabledRecords = $this->registry + ->get(self::REGISTRY_NAMESPACE, $this->getRegistryKey($file->getUid()), []); + if ($disabledRecords === []) { + return; + } + + $reEnabledAny = false; + foreach ($disabledRecords as $disabledRecord) { + if ($this->reEnableRecord($disabledRecord['table'], (int)$disabledRecord['uid'], $file)) { + $reEnabledAny = true; + } + } + + $this->registry->remove(self::REGISTRY_NAMESPACE, $this->getRegistryKey($file->getUid())); + + if ($reEnabledAny) { + $this->flushPageCache(); + } + } + + private function disableRecord(string $table, int $uid, File $file): bool + { + $enableColumn = $this->getDisabledColumn($table); + if ($enableColumn === null) { + return false; + } + + if ($this->isRecordHidden($table, $uid, $enableColumn)) { + return false; + } + + $this->connectionPool->getConnectionForTable($table)->update($table, [$enableColumn => 1], ['uid' => $uid]); + + $this->addMessage( + $table, + $uid, + str_replace('{fileName}', $file->getName(), $this->translate('metaData.log.recordDisabled')), + ); + + return true; + } + + private function reEnableRecord(string $table, int $uid, File $file): bool + { + $enableColumn = $this->getDisabledColumn($table); + if ($enableColumn === null) { + return false; + } + + if (!$this->isRecordHidden($table, $uid, $enableColumn)) { + return false; + } + + $this->connectionPool->getConnectionForTable($table)->update( + $table, + [$enableColumn => 0], + ['uid' => $uid] + ); + + $this->addMessage( + $table, + $uid, + str_replace('{fileName}', $file->getName(), $this->translate('metaData.log.fileReferenceReenabled')), + ); + + return true; + } + + private function isRecordHidden(string $table, int $uid, string $enableColumn): bool + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll(); + + $value = $queryBuilder + ->select($enableColumn) + ->from($table) + ->where( + $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)), + ) + ->executeQuery() + ->fetchOne(); + + return (bool)$value; + } + + private function getDisabledColumn(string $table): ?string + { + $column = $GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'] ?? null; + + return is_string($column) && $column !== '' ? $column : null; + } + + private function addMessage(string $table, int $uid, string $message): void + { + if (($GLOBALS['BE_USER'] ?? null) instanceof BackendUserAuthentication) { + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start([], []); + $dataHandler->log( + $table, + $uid, + SystemLogDatabaseAction::UPDATE, + null, + SystemLogErrorClassification::WARNING, + $message + ); + } + + $flashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); + $flashMessageQueue->addMessage( + GeneralUtility::makeInstance( + FlashMessage::class, + $message, + LocalizationUtility::translate( + 'LLL:EXT:jwtools2/Resources/Private/Language/locallang_mod.xlf:fileBrowser.flashMessage.requiredColumns.title', + ) ?? '', + ContextualFeedbackSeverity::WARNING, + ), + ); + } + + private function flushPageCache(): void + { + $this->cacheManager->flushCachesInGroup('pages'); + } + + private function getRegistryKey(int $fileUid): string + { + return 'disabledRecordsForFile_' . $fileUid; + } + + private function getFile(int $fileUid): ?File + { + try { + return $this->resourceFactory->getFileObject($fileUid); + } catch (FileDoesNotExistException) { + return null; + } + } + + private function isEnabled(): bool + { + try { + return (bool)$this->extensionConfiguration->get( + 'jwtools2', + 'typo3DisableRecordsOnInvalidFileMetaData' + ); + } catch (ExtensionConfigurationExtensionNotConfiguredException|ExtensionConfigurationPathDoesNotExistException) { + return false; + } + } + + private function translate(string $key): string + { + return LocalizationUtility::translate( + 'LLL:EXT:jwtools2/Resources/Private/Language/locallang_mod.xlf:' . $key + ) ?? $key; + } +} From cee579a453f1766c28e424a9cc575b7ad5cb48b6 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 14:14:24 +0200 Subject: [PATCH 39/52] [TASK] Introduce FileMetaDataController with advanced filtering and pagination - Added `FileMetaDataController` to manage file metadata with robust filtering and pagination functionality. - Includes methods for metadata validation, reference resolution, and query building. --- Classes/Controller/FileMetaDataController.php | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 Classes/Controller/FileMetaDataController.php diff --git a/Classes/Controller/FileMetaDataController.php b/Classes/Controller/FileMetaDataController.php new file mode 100644 index 0000000..d1b10ec --- /dev/null +++ b/Classes/Controller/FileMetaDataController.php @@ -0,0 +1,318 @@ + $filters + * @throws Exception + */ + public function listAction(array $filters = []): ResponseInterface + { + $filters = $this->getFilters($filters); + $currentPage = max(1, (int)($filters['page'] ?? 1)); + $requiresPhpFiltering = $filters['status'] !== 'all' + || $filters['missingColumn'] !== '' + || $filters['referenceScope'] !== 'all'; + + $queryBuilder = $this->buildBaseQueryBuilder($filters); + $truncated = false; + + if ($requiresPhpFiltering) { + $candidateUids = (clone $queryBuilder) + ->select('sys_file.uid') + ->orderBy('sys_file.name') + ->setMaxResults(self::CANDIDATE_LIMIT) + ->executeQuery() + ->fetchFirstColumn(); + + $truncated = count($candidateUids) >= self::CANDIDATE_LIMIT; + + $allRows = $this->buildRows(array_map(intval(...), $candidateUids), $filters); + $totalCount = count($allRows); + $pageRows = array_slice($allRows, ($currentPage - 1) * self::ITEMS_PER_PAGE, self::ITEMS_PER_PAGE); + } else { + $totalCount = (int)(clone $queryBuilder)->count('sys_file.uid')->executeQuery()->fetchOne(); + + $pageUids = (clone $queryBuilder) + ->select('sys_file.uid') + ->orderBy('sys_file.name') + ->setFirstResult(($currentPage - 1) * self::ITEMS_PER_PAGE) + ->setMaxResults(self::ITEMS_PER_PAGE) + ->executeQuery() + ->fetchFirstColumn(); + + $pageRows = $this->buildRows(array_map(intval(...), $pageUids), $filters); + } + + $requiredColumns = $this->fileMetaDataValidationService->getRequiredColumns(); + $extensionOptions = $this->getExtensionOptions(); + + $this->moduleTemplate->assignMultiple([ + 'rows' => $pageRows, + 'filters' => $filters, + 'requiredColumns' => $requiredColumns, + 'requiredColumnOptions' => array_combine($requiredColumns, $requiredColumns), + 'storageOptions' => $this->getStorageOptions(), + 'extensionOptions' => array_combine($extensionOptions, $extensionOptions), + 'currentPage' => $currentPage, + 'totalPages' => $totalPages = max(1, (int)ceil($totalCount / self::ITEMS_PER_PAGE)), + 'totalCount' => $totalCount, + 'truncated' => $truncated, + 'candidateLimit' => self::CANDIDATE_LIMIT, + 'previousPageFilters' => [...$filters, 'page' => (string)max(1, $currentPage - 1)], + 'nextPageFilters' => [...$filters, 'page' => (string)min($totalPages, $currentPage + 1)], + ]); + + return $this->moduleTemplate->renderResponse('FileMetaData/List'); + } + + /** + * @param array $fileUids + * @return array> + */ + protected function buildRows(array $fileUids, array $filters): array + { + $requiredColumns = $this->fileMetaDataValidationService->getRequiredColumns(); + $rows = []; + + foreach ($fileUids as $fileUid) { + $file = $this->getFile($fileUid); + if (!$file instanceof File) { + continue; + } + + $missingColumns = $this->fileMetaDataValidationService->getMissingColumns($file); + $status = $this->getStatus($missingColumns, $requiredColumns); + + if ($filters['status'] !== 'all' && $filters['status'] !== $status) { + continue; + } + + if ($filters['missingColumn'] !== '' && !in_array($filters['missingColumn'], $missingColumns, true)) { + continue; + } + + $references = $this->fileReferenceResolverService->getReferenceIndexRowsForFile($fileUid); + $referenceScope = $this->getReferenceScope($references); + + if ($filters['referenceScope'] !== 'all' && $filters['referenceScope'] !== $referenceScope) { + continue; + } + + $rows[] = [ + 'file' => $file, + 'status' => $status, + 'missingColumns' => $missingColumns, + 'references' => $references, + 'referenceScope' => $referenceScope, + ]; + } + + return $rows; + } + + /** + * @param array $missingColumns + * @param array $requiredColumns + */ + protected function getStatus(array $missingColumns, array $requiredColumns): string + { + if ($missingColumns === []) { + return 'valid'; + } + + if ($requiredColumns !== [] && count($missingColumns) === count($requiredColumns)) { + return 'missing'; + } + + return 'partial'; + } + + protected function getReferenceScope(array $references): string + { + if ($references === []) { + return 'orphaned'; + } + + $hasContentElement = false; + $hasOther = false; + + foreach ($references as $reference) { + if ($reference['tablename'] === 'tt_content') { + $hasContentElement = true; + } else { + $hasOther = true; + } + } + + return match (true) { + $hasContentElement && $hasOther => 'both', + $hasContentElement => 'tt_content', + default => 'other', + }; + } + + protected function buildBaseQueryBuilder(array $filters): QueryBuilder + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $queryBuilder + ->from('sys_file') + ->where( + $queryBuilder->expr()->eq( + 'sys_file.type', + $queryBuilder->createNamedParameter(FileType::IMAGE->value, Connection::PARAM_INT), + ), + ); + + if ($filters['storage'] !== '') { + $queryBuilder->andWhere( + $queryBuilder->expr()->eq( + 'sys_file.storage', + $queryBuilder->createNamedParameter((int)$filters['storage'], Connection::PARAM_INT), + ), + ); + } + + if ($filters['extension'] !== '') { + $queryBuilder->andWhere( + $queryBuilder->expr()->eq( + 'sys_file.extension', + $queryBuilder->createNamedParameter($filters['extension']), + ), + ); + } + + if ($filters['search'] !== '') { + $connection = $this->connectionPool->getConnectionForTable('sys_file'); + $queryBuilder->andWhere( + $queryBuilder->expr()->like( + 'sys_file.name', + $queryBuilder->createNamedParameter( + '%' . $connection->escapeLikeWildcards($filters['search']) . '%', + ), + ), + ); + } + + return $queryBuilder; + } + + /** + * @param array $requestFilters + * @return array + */ + protected function getFilters(array $requestFilters): array + { + return [ + 'status' => (string)($requestFilters['status'] ?? 'all'), + 'missingColumn' => (string)($requestFilters['missingColumn'] ?? ''), + 'referenceScope' => (string)($requestFilters['referenceScope'] ?? 'all'), + 'storage' => (string)($requestFilters['storage'] ?? ''), + 'extension' => (string)($requestFilters['extension'] ?? ''), + 'search' => trim((string)($requestFilters['search'] ?? '')), + 'page' => (string)($requestFilters['page'] ?? '1'), + ]; + } + + /** + * @return array + * @throws Exception + */ + protected function getStorageOptions(): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_storage'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $rows = $queryBuilder + ->select('uid', 'name') + ->from('sys_file_storage') + ->orderBy('name') + ->executeQuery() + ->fetchAllAssociative(); + + return array_map( + static fn(array $row): array => ['uid' => (int)$row['uid'], 'name' => (string)$row['name']], + $rows, + ); + } + + /** + * @return array + * @throws Exception + */ + protected function getExtensionOptions(): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file'); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $rows = $queryBuilder + ->selectLiteral('DISTINCT ' . $queryBuilder->quoteIdentifier('extension')) + ->from('sys_file') + ->where( + $queryBuilder->expr()->eq( + 'sys_file.type', + $queryBuilder->createNamedParameter(\TYPO3\CMS\Core\Resource\FileType::IMAGE->value, Connection::PARAM_INT), + ), + ) + ->orderBy('extension') + ->executeQuery() + ->fetchFirstColumn(); + + return array_values(array_filter(array_map(strval(...), $rows))); + } + + protected function getFile(int $fileUid): ?File + { + try { + return $this->resourceFactory->getFileObject($fileUid); + } catch (FileDoesNotExistException) { + return null; + } + } +} From ad3e2fa732db3dfdae1b278f50b973cc9f0c23d9 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 14:16:20 +0200 Subject: [PATCH 40/52] [TASK] Add FileMetaDataValidationService for validating required file metadata - Introduced `FileMetaDataValidationService` to validate file metadata against required columns from extension configuration. - Provides methods to check missing metadata, validate file types, and improve metadata handling. --- .../Service/FileMetaDataValidationService.php | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 Classes/Service/FileMetaDataValidationService.php diff --git a/Classes/Service/FileMetaDataValidationService.php b/Classes/Service/FileMetaDataValidationService.php new file mode 100644 index 0000000..85b686c --- /dev/null +++ b/Classes/Service/FileMetaDataValidationService.php @@ -0,0 +1,111 @@ +requiredColumnsCache === null) { + $validColumns = []; + foreach ($this->getRequiredColumnsFromExtensionConfiguration() as $column) { + if ($this->isValidColumn($column)) { + $validColumns[] = $column; + } + } + + $this->requiredColumnsCache = $validColumns; + } + + return $this->requiredColumnsCache; + } + + public function getMissingColumns(File $file): array + { + if ($file->getType() !== FileType::IMAGE->value) { + return []; + } + + $missingColumns = []; + $properties = $file->getProperties(); + + foreach ($this->getRequiredColumns() as $requiredColumn) { + // Do not use isset() as "null" values have to be tested, too. + if (!array_key_exists($requiredColumn, $properties)) { + $missingColumns[] = $requiredColumn; + continue; + } + + $value = $properties[$requiredColumn]; + $value = is_string($value) ? trim($value) : $value; + + if ($value === null || $value === '') { + $missingColumns[] = $requiredColumn; + } + } + + return $missingColumns; + } + + public function hasValidMetaData(File $file): bool + { + return $this->getMissingColumns($file) === []; + } + + protected function getRequiredColumnsFromExtensionConfiguration(): array + { + try { + return GeneralUtility::trimExplode( + ',', + (string)$this->extensionConfiguration->get('jwtools2', 'typo3RequiredColumnsForFiles'), + true, + ); + } catch (ExtensionConfigurationExtensionNotConfiguredException|ExtensionConfigurationPathDoesNotExistException) { + return []; + } + } + + protected function isValidColumn(string $column, string $table = 'sys_file'): bool + { + $columnExists = false; + $connection = $this->getConnectionPool()->getConnectionForTable($table); + $schemaManager = $connection->createSchemaManager(); + if ($schemaManager->tablesExist([$table])) { + $columnExists = $schemaManager->introspectTable($table)->hasColumn($column); + if ($columnExists === false && $table !== 'sys_file_metadata') { + $columnExists = $this->isValidColumn($column, 'sys_file_metadata'); + } + } + + return $columnExists; + } + + protected function getConnectionPool(): ConnectionPool + { + return $this->connectionPool; + } +} From 68776fb02489484e5cdf21bde9003a77595c59c1 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 14:16:36 +0200 Subject: [PATCH 41/52] [TASK] Add FileMetaData/List.html template for metadata listing - Introduced a new Fluid template to display file metadata in a list format with advanced filtering, pagination, and reference handling. - Includes support for localized strings, user-friendly filtering controls, and status badges. - Enhances backend module functionality for managing file metadata dynamically. --- .../Private/Templates/FileMetaData/List.html | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 Resources/Private/Templates/FileMetaData/List.html diff --git a/Resources/Private/Templates/FileMetaData/List.html b/Resources/Private/Templates/FileMetaData/List.html new file mode 100644 index 0000000..14c9a23 --- /dev/null +++ b/Resources/Private/Templates/FileMetaData/List.html @@ -0,0 +1,172 @@ + + + + + + + +

{f:translate(key: 'fileMetaData.title')}

+

{f:translate(key: 'fileMetaData.description')}

+ + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{f:translate(key: 'fileMetaData.table.thumbnail')}{f:translate(key: 'fileMetaData.table.name')}{f:translate(key: 'fileMetaData.table.status')}{f:translate(key: 'fileMetaData.table.missingColumns')}{f:translate(key: 'fileMetaData.table.references')}{f:translate(key: 'fileMetaData.table.actions')}
+ + + {row.file.name}
+ {row.file.storage.name} - {row.file.extension} +
+ + {f:translate(key: 'fileMetaData.status.valid')} + {f:translate(key: 'fileMetaData.status.partial')} + {f:translate(key: 'fileMetaData.status.missing')} + + + + {missingColumn}, + + + + +
    + +
  • + + {reference.tablename}:{reference.recuid} ({reference.field}) + +
  • +
    +
+
+ + {f:translate(key: 'fileMetaData.references.none')} + +
+
+ + + {f:translate(key: 'fileMetaData.actions.editMetadata')} + + +
{f:translate(key: 'fileMetaData.noResults')}
+ + +
+ From d48e512f3536798dd484519b3b1fe99d24536acc Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 14:17:42 +0200 Subject: [PATCH 42/52] [TASK] Add FileReferenceResolverService for file reference resolution - Introduced `FileReferenceResolverService` to handle file reference lookups via `sys_file_reference` and `sys_refindex` tables. - Includes methods for retrieving direct and indexed file references with support for hidden state filtering. - Enhances file reference management with robust data resolution. --- .../Service/FileReferenceResolverService.php | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 Classes/Service/FileReferenceResolverService.php diff --git a/Classes/Service/FileReferenceResolverService.php b/Classes/Service/FileReferenceResolverService.php new file mode 100644 index 0000000..200c28b --- /dev/null +++ b/Classes/Service/FileReferenceResolverService.php @@ -0,0 +1,152 @@ + + * @throws Exception + */ + public function getFileReferencesForFile(int $fileUid, bool $includeHidden = true): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_reference'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $queryBuilder + ->select('uid', 'tablenames', 'uid_foreign', 'fieldname', 'hidden') + ->from('sys_file_reference') + ->where( + $queryBuilder->expr()->eq( + 'uid_local', + $queryBuilder->createNamedParameter($fileUid, Connection::PARAM_INT), + ), + ); + + if (!$includeHidden) { + $queryBuilder->andWhere( + $queryBuilder->expr()->eq('hidden', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)), + ); + } + + $rows = $queryBuilder->executeQuery()->fetchAllAssociative(); + + return array_map( + static fn(array $row): array => [ + 'uid' => (int)$row['uid'], + 'tablenames' => (string)$row['tablenames'], + 'uid_foreign' => (int)$row['uid_foreign'], + 'fieldname' => (string)$row['fieldname'], + 'hidden' => (bool)$row['hidden'], + ], + $rows, + ); + } + + /** + * Broader lookup via sys_refindex, resolving "sys_file_reference" bridge rows to their + * owning record. Used for reporting (the backend module), where we want to show every + * table/field a file is used in, not just decide whether to hide something. + * + * @return array + * @throws Exception + */ + public function getReferenceIndexRowsForFile(int $fileUid): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex'); + $queryBuilder->getRestrictions()->removeAll(); + + $rows = $queryBuilder + ->select('*') + ->from('sys_refindex') + ->where( + $queryBuilder->expr()->eq( + 'ref_table', + $queryBuilder->createNamedParameter('sys_file', Connection::PARAM_STR), + ), + $queryBuilder->expr()->eq( + 'ref_uid', + $queryBuilder->createNamedParameter($fileUid, Connection::PARAM_INT), + ), + ) + ->executeQuery() + ->fetchAllAssociative(); + + $references = []; + foreach ($rows as $row) { + if ($row['tablename'] === 'sys_file_reference') { + $resolved = $this->resolveFileReferenceToOwningRecord((int)$row['recuid']); + if ($resolved === null) { + // Orphaned sys_file_reference row (owning record removed without cleanup) - skip it. + continue; + } + + $references[] = $resolved; + continue; + } + + $references[] = [ + 'tablename' => (string)$row['tablename'], + 'recuid' => (int)$row['recuid'], + 'field' => (string)$row['field'], + ]; + } + + return $references; + } + + /** + * @return array{tablename: string, recuid: int, field: string}|null + * @throws Exception + */ + protected function resolveFileReferenceToOwningRecord(int $referenceUid): ?array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_reference'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $fileReference = $queryBuilder + ->select('tablenames', 'uid_foreign', 'fieldname') + ->from('sys_file_reference') + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($referenceUid, Connection::PARAM_INT), + ), + ) + ->executeQuery() + ->fetchAssociative(); + + if ($fileReference === false) { + return null; + } + + return [ + 'tablename' => (string)$fileReference['tablenames'], + 'recuid' => (int)$fileReference['uid_foreign'], + 'field' => (string)$fileReference['fieldname'], + ]; + } +} From 79009e8f4ff43d12d913de219add61ac9786ed17 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 14:18:50 +0200 Subject: [PATCH 43/52] [TASK] Add PlaceholderImageService for dynamic placeholder management - Introduced `PlaceholderImageService` to manage dynamic placeholder image generation and caching. - Handles API requests, file storage, fallback logic, and content type validation. - Enhances placeholder image handling with robust configuration and error management. --- Classes/Service/PlaceholderImageService.php | 245 ++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 Classes/Service/PlaceholderImageService.php diff --git a/Classes/Service/PlaceholderImageService.php b/Classes/Service/PlaceholderImageService.php new file mode 100644 index 0000000..9d874af --- /dev/null +++ b/Classes/Service/PlaceholderImageService.php @@ -0,0 +1,245 @@ +getCacheFolder(); + if (!$folder instanceof Folder) { + return null; + } + + $width = max(1, $width); + $height = max(1, $height); + + $existingFile = $this->getExistingPlaceholderFile($folder, $width, $height); + if ($existingFile instanceof File) { + return $existingFile; + } + + return $this->fetchAndCache($folder, $width, $height); + } + + protected function getExistingPlaceholderFile(Folder $folder, int $width, int $height): ?File + { + foreach (self::PLACEHOLDER_FILE_EXTENSIONS as $extension) { + $existingFile = $this->getExistingFile($folder, sprintf('placeholder_%dx%d.%s', $width, $height, $extension)); + if ($existingFile instanceof File) { + return $existingFile; + } + } + + return null; + } + + protected function fetchAndCache(Folder $folder, int $width, int $height): ?File + { + $apiUrl = $this->buildApiUrl($width, $height); + if ($apiUrl === null) { + return $this->getFallbackFile($folder); + } + + try { + $response = $this->requestFactory->request($apiUrl); + if ($response->getStatusCode() !== 200) { + throw new \RuntimeException( + 'Placeholder image API returned status ' . $response->getStatusCode(), + 1732000001, + ); + } + + $content = $response->getBody()->getContents(); + $extension = $this->getExtensionForContentType($response->getHeaderLine('Content-Type')); + if ($extension === null) { + throw new \RuntimeException( + 'Placeholder image API returned a non-rasterizable content type: ' . $response->getHeaderLine('Content-Type'), + 1732000002, + ); + } + } catch (\Throwable $exception) { + $this->logger?->warning( + 'Could not fetch placeholder image from API, falling back to bundled placeholder', + ['exception' => $exception, 'apiUrl' => $apiUrl], + ); + + return $this->getFallbackFile($folder); + } + + $fileName = sprintf('placeholder_%dx%d.%s', $width, $height, $extension); + $storedFile = $this->storeContentAsFile($folder, $fileName, $content); + + return $storedFile ?? $this->getFallbackFile($folder); + } + + protected function getExtensionForContentType(string $contentType): ?string + { + return match (strtolower(trim(explode(';', $contentType, 2)[0]))) { + 'image/png' => 'png', + 'image/jpeg', 'image/jpg' => 'jpg', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + default => null, + }; + } + + protected function storeContentAsFile(Folder $folder, string $fileName, string $content): ?File + { + $temporaryFile = GeneralUtility::tempnam('jwtools2_placeholder_', '.' . pathinfo($fileName, PATHINFO_EXTENSION)); + if (!GeneralUtility::writeFile($temporaryFile, $content)) { + return null; + } + + try { + $file = $folder->addFile($temporaryFile, $fileName, DuplicationBehavior::REPLACE); + } catch (\Throwable $exception) { + $this->logger?->warning( + 'Could not store downloaded placeholder image in FAL', + ['exception' => $exception, 'fileName' => $fileName], + ); + + return null; + } finally { + if (is_file($temporaryFile)) { + unlink($temporaryFile); + } + } + + return $file instanceof File ? $file : null; + } + + protected function getFallbackFile(Folder $folder): ?File + { + $existingFile = $this->getExistingFile($folder, self::FALLBACK_FILE_NAME); + if ($existingFile instanceof File) { + return $existingFile; + } + + $bundledPath = GeneralUtility::getFileAbsFileName( + 'EXT:jwtools2/Resources/Public/Images/MetaDataPlaceholder.png', + ); + if (!is_file($bundledPath)) { + return null; + } + + try { + $file = $folder->addFile($bundledPath, self::FALLBACK_FILE_NAME, DuplicationBehavior::REPLACE); + } catch (\Throwable $exception) { + $this->logger?->warning( + 'Could not store bundled fallback placeholder image in FAL', + ['exception' => $exception], + ); + + return null; + } + + return $file instanceof File ? $file : null; + } + + protected function getExistingFile(Folder $folder, string $fileName): ?File + { + if (!$folder->hasFile($fileName)) { + return null; + } + + $file = $folder->getFile($fileName); + + return $file instanceof File ? $file : null; + } + + protected function buildApiUrl(int $width, int $height): ?string + { + $template = trim($this->getConfigurationValue('typo3PlaceholderImageApiUrl')); + if ($template === '') { + return null; + } + + return str_replace(['{width}', '{height}'], [(string)$width, (string)$height], $template); + } + + protected function getCacheFolder(): ?Folder + { + $identifier = trim($this->getConfigurationValue('typo3PlaceholderImageStorageFolder')); + if ($identifier === '') { + return null; + } + + $parts = explode(':', $identifier, 2); + if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') { + return null; + } + + [$storageUid, $folderPath] = $parts; + + try { + $storage = $this->resourceFactory->getStorageObject((int)$storageUid); + } catch (\Throwable $exception) { + $this->logger?->warning( + 'Configured placeholder image storage does not exist', + ['exception' => $exception, 'identifier' => $identifier], + ); + + return null; + } + + if ($storage->hasFolder($folderPath)) { + return $storage->getFolder($folderPath); + } + + try { + return $storage->createFolder($folderPath); + } catch (\Throwable $exception) { + $this->logger?->warning( + 'Could not create placeholder image cache folder', + ['exception' => $exception, 'identifier' => $identifier], + ); + + return null; + } + } + + protected function getConfigurationValue(string $key): string + { + try { + return (string)$this->extensionConfiguration->get('jwtools2', $key); + } catch (ExtensionConfigurationExtensionNotConfiguredException|ExtensionConfigurationPathDoesNotExistException) { + return ''; + } + } +} From 38a08fc30d048159bc72c3dbe2b34e2f8fe3ac8e Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 14:21:31 +0200 Subject: [PATCH 44/52] [TASK] Add ReplaceInvalidMetaDataImageEventListener for handling invalid image metadata - Introduced `ReplaceInvalidMetaDataImageEventListener` to replace frontend images with invalid metadata using placeholder images. - Validates metadata, replaces invalid images, and processes placeholders with configurable dimensions. - Ensures functionality is enabled only for frontend requests and configurable via extension settings. --- ...placeInvalidMetaDataImageEventListener.php | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 Classes/EventListener/ReplaceInvalidMetaDataImageEventListener.php diff --git a/Classes/EventListener/ReplaceInvalidMetaDataImageEventListener.php b/Classes/EventListener/ReplaceInvalidMetaDataImageEventListener.php new file mode 100644 index 0000000..82d41f9 --- /dev/null +++ b/Classes/EventListener/ReplaceInvalidMetaDataImageEventListener.php @@ -0,0 +1,108 @@ +isEnabled() || !$this->isFrontendRequest()) { + return; + } + + $file = $event->getFile(); + if (!$file instanceof File || $file->getType() !== FileType::IMAGE->value) { + return; + } + + if ($this->isPlaceholderFile($file)) { + return; + } + + if ($this->fileMetaDataValidationService->hasValidMetaData($file)) { + return; + } + + $configuration = $event->getConfiguration(); + $placeholder = $this->placeholderImageService->getPlaceholderFile( + $this->extractDimension($configuration['width'] ?? null, 300), + $this->extractDimension($configuration['height'] ?? null, 300), + ); + + if (!$placeholder instanceof File) { + return; + } + + try { + $processedFile = $placeholder->process($event->getTaskType(), $configuration); + } catch (\Throwable) { + return; + } + + $event->setProcessedFile($processedFile); + } + + private function isPlaceholderFile(File $file): bool + { + return str_starts_with($file->getName(), self::PLACEHOLDER_FILE_NAME_PREFIX); + } + + private function extractDimension(mixed $value, int $default): int + { + if ($value === null || $value === '') { + return $default; + } + + $dimension = (int)$value; + + return $dimension > 0 ? $dimension : $default; + } + + private function isFrontendRequest(): bool + { + $request = $GLOBALS['TYPO3_REQUEST'] ?? null; + + return $request instanceof ServerRequestInterface + && ApplicationType::fromRequest($request)->isFrontend(); + } + + private function isEnabled(): bool + { + try { + return (bool)$this->extensionConfiguration->get( + 'jwtools2', + 'typo3ReplaceFrontendImagesWithInvalidMetaData', + ); + } catch (ExtensionConfigurationExtensionNotConfiguredException|ExtensionConfigurationPathDoesNotExistException) { + return false; + } + } +} From 12b5434a3f77004d0da911a2bab555dc3361773a Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 14:22:06 +0200 Subject: [PATCH 45/52] [TASK] Add ValidateFileMetaDataOnSaveHook to disable references with invalid metadata - Introduced `ValidateFileMetaDataOnSaveHook` to handle post-save operations in the DataHandler. - Automatically validates file references' metadata and disables those with invalid metadata. - Includes detailed logging and extension configuration support for enabling or disabling the functionality. --- .../Hooks/ValidateFileMetaDataOnSaveHook.php | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 Classes/Hooks/ValidateFileMetaDataOnSaveHook.php diff --git a/Classes/Hooks/ValidateFileMetaDataOnSaveHook.php b/Classes/Hooks/ValidateFileMetaDataOnSaveHook.php new file mode 100644 index 0000000..20d7543 --- /dev/null +++ b/Classes/Hooks/ValidateFileMetaDataOnSaveHook.php @@ -0,0 +1,149 @@ +isEnabled()) { + return; + } + + foreach ($dataHandler->datamap as $table => $records) { + foreach (array_keys($records) as $submittedId) { + $uid = $this->resolveRealUid((string)$submittedId, $dataHandler); + if ($uid === null) { + continue; + } + + $this->validateFileReferences($table, $uid, $dataHandler); + } + } + } + + protected function resolveRealUid(string $submittedId, DataHandler $dataHandler): ?int + { + if (MathUtility::canBeInterpretedAsInteger($submittedId)) { + return (int)$submittedId; + } + + $realId = $dataHandler->substNEWwithIDs[$submittedId] ?? null; + + return $realId !== null ? (int)$realId : null; + } + + protected function validateFileReferences(string $table, int $uid, DataHandler $dataHandler): void + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_reference'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $references = $queryBuilder + ->select('uid', 'uid_local', 'fieldname') + ->from('sys_file_reference') + ->where( + $queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter($table)), + $queryBuilder->expr()->eq( + 'uid_foreign', + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT), + ), + $queryBuilder->expr()->eq('hidden', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)), + ) + ->executeQuery() + ->fetchAllAssociative(); + + foreach ($references as $reference) { + $file = $this->getFile((int)$reference['uid_local']); + if (!$file instanceof File || $this->fileMetaDataValidationService->hasValidMetaData($file)) { + continue; + } + + $this->disableFileReference((int)$reference['uid']); + + $dataHandler->log( + $table, + $uid, + SystemLogDatabaseAction::UPDATE, + null, + SystemLogErrorClassification::USER_ERROR, + $this->translate('dataHandler.log.invalidFileMetaData'), + null, + ['field' => $reference['fieldname'], 'file' => $file->getName()], + ); + } + } + + protected function disableFileReference(int $referenceUid): void + { + $this->connectionPool + ->getConnectionForTable('sys_file_reference') + ->update( + 'sys_file_reference', + ['hidden' => 1], + ['uid' => $referenceUid], + ); + } + + protected function getFile(int $fileUid): ?File + { + try { + return $this->resourceFactory->getFileObject($fileUid); + } catch (FileDoesNotExistException) { + return null; + } + } + + protected function isEnabled(): bool + { + try { + return (bool)$this->extensionConfiguration->get( + 'jwtools2', + 'typo3PreventSavingContentWithInvalidFileMetaData', + ); + } catch (ExtensionConfigurationExtensionNotConfiguredException|ExtensionConfigurationPathDoesNotExistException) { + return false; + } + } + + protected function translate(string $key): string + { + return LocalizationUtility::translate( + 'LLL:EXT:jwtools2/Resources/Private/Language/locallang_mod.xlf:' . $key + ) ?? $key; + } +} From 8e58b0b162d36a45f49f7e667be1f4bf0f12f163 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 14:22:43 +0200 Subject: [PATCH 46/52] [TASK] Enable page ID display alongside titles in page tree configuration - Added TypoScript configuration to display page IDs alongside titles in the page tree. - Improves clarity and identification of pages in the backend. --- Configuration/user.tsconfig | 1 + 1 file changed, 1 insertion(+) create mode 100644 Configuration/user.tsconfig diff --git a/Configuration/user.tsconfig b/Configuration/user.tsconfig new file mode 100644 index 0000000..fbc75cd --- /dev/null +++ b/Configuration/user.tsconfig @@ -0,0 +1 @@ +options.pageTree.showPageIdWithTitle = 1 From 9b78c58a195564f927d1f1789f54617bfd6acb3d Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 14:30:56 +0200 Subject: [PATCH 47/52] [TASK] Remove redundant type declarations for constants across multiple classes - Removed unnecessary `string`, `int`, and `array` type declarations for constants in various classes to comply with PHP syntax rules. - Simplifies code and enhances readability while maintaining functionality. --- Classes/Command/StatusReportCommand.php | 4 ++-- Classes/Controller/FileMetaDataController.php | 4 ++-- .../DisableRecordsWithInvalidFileMetaDataEventListener.php | 2 +- .../ReplaceInvalidMetaDataImageEventListener.php | 2 +- Classes/Service/PlaceholderImageService.php | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Classes/Command/StatusReportCommand.php b/Classes/Command/StatusReportCommand.php index 7835940..6b50c87 100644 --- a/Classes/Command/StatusReportCommand.php +++ b/Classes/Command/StatusReportCommand.php @@ -29,9 +29,9 @@ */ class StatusReportCommand extends Command { - private const string RETURN_YES = 'YES'; + private const RETURN_YES = 'YES'; - private const string RETURN_NO = 'NO'; + private const RETURN_NO = 'NO'; private SchedulerTaskRepository $taskRepository; diff --git a/Classes/Controller/FileMetaDataController.php b/Classes/Controller/FileMetaDataController.php index d1b10ec..9547976 100644 --- a/Classes/Controller/FileMetaDataController.php +++ b/Classes/Controller/FileMetaDataController.php @@ -28,9 +28,9 @@ class FileMetaDataController extends AbstractController { - private const int ITEMS_PER_PAGE = 50; + private const ITEMS_PER_PAGE = 50; - private const int CANDIDATE_LIMIT = 1000; + private const CANDIDATE_LIMIT = 1000; public function __construct( ModuleTemplateFactory $moduleTemplateFactory, diff --git a/Classes/EventListener/DisableRecordsWithInvalidFileMetaDataEventListener.php b/Classes/EventListener/DisableRecordsWithInvalidFileMetaDataEventListener.php index 0642387..856e284 100644 --- a/Classes/EventListener/DisableRecordsWithInvalidFileMetaDataEventListener.php +++ b/Classes/EventListener/DisableRecordsWithInvalidFileMetaDataEventListener.php @@ -37,7 +37,7 @@ final readonly class DisableRecordsWithInvalidFileMetaDataEventListener { - private const string REGISTRY_NAMESPACE = 'jwtools2'; + private const REGISTRY_NAMESPACE = 'jwtools2'; public function __construct( private ExtensionConfiguration $extensionConfiguration, diff --git a/Classes/EventListener/ReplaceInvalidMetaDataImageEventListener.php b/Classes/EventListener/ReplaceInvalidMetaDataImageEventListener.php index 82d41f9..277390c 100644 --- a/Classes/EventListener/ReplaceInvalidMetaDataImageEventListener.php +++ b/Classes/EventListener/ReplaceInvalidMetaDataImageEventListener.php @@ -24,7 +24,7 @@ final readonly class ReplaceInvalidMetaDataImageEventListener { - private const string PLACEHOLDER_FILE_NAME_PREFIX = 'placeholder_'; + private const PLACEHOLDER_FILE_NAME_PREFIX = 'placeholder_'; public function __construct( private ExtensionConfiguration $extensionConfiguration, diff --git a/Classes/Service/PlaceholderImageService.php b/Classes/Service/PlaceholderImageService.php index 9d874af..829239e 100644 --- a/Classes/Service/PlaceholderImageService.php +++ b/Classes/Service/PlaceholderImageService.php @@ -27,9 +27,9 @@ final class PlaceholderImageService implements LoggerAwareInterface { use LoggerAwareTrait; - private const string FALLBACK_FILE_NAME = 'placeholder_fallback.png'; + private const FALLBACK_FILE_NAME = 'placeholder_fallback.png'; - private const array PLACEHOLDER_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp']; + private const PLACEHOLDER_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp']; public function __construct( private readonly ExtensionConfiguration $extensionConfiguration, From 42deedb27fea6e2b67b1500c8292c803838f2b76 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 14:33:32 +0200 Subject: [PATCH 48/52] [TASK] Remove redundant trailing commas and empty line breaks across multiple classes - Cleaned up trailing commas in parameter lists, array definitions, and method calls. - Merged single-line and empty-line constructors for consistency. - Enhanced code readability and adherence to coding standards. --- Classes/Command/ConvertPlainPasswordToHashCommand.php | 2 +- Classes/Controller/Ajax/SysFileController.php | 8 ++++---- Classes/Controller/SolrController.php | 2 +- Classes/Database/Query/QueryGenerator.php | 4 +--- ...ableRecordsWithInvalidFileMetaDataEventListener.php | 10 +++++----- Classes/EventListener/IndexServiceEventListener.php | 3 +-- Classes/Hooks/CachingFrameworkLoggerHook.php | 3 +-- Classes/Hooks/IndexService.php | 3 +-- Classes/Hooks/ModifyElementInformationHook.php | 4 ++-- Classes/Hooks/MoveTranslatedContentElementsHook.php | 3 +-- Classes/Hooks/ValidateFileMetaDataOnSaveHook.php | 2 +- Classes/Service/FileMetaDataValidationService.php | 2 +- Classes/Service/PlaceholderImageService.php | 8 +++----- Classes/Service/SolrService.php | 3 +-- Classes/Task/ExecuteQueryTask.php | 5 ++--- Configuration/JavaScriptModules.php | 1 + Configuration/TCA/tx_jwtools2_cache_expression.php | 1 + ext_emconf.php | 1 + 18 files changed, 29 insertions(+), 36 deletions(-) diff --git a/Classes/Command/ConvertPlainPasswordToHashCommand.php b/Classes/Command/ConvertPlainPasswordToHashCommand.php index 556cbab..500ca6b 100644 --- a/Classes/Command/ConvertPlainPasswordToHashCommand.php +++ b/Classes/Command/ConvertPlainPasswordToHashCommand.php @@ -134,7 +134,7 @@ protected function getNewHashedPassword(string $password, string $mode): string OutputInterface::VERBOSITY_VERBOSE, ); $this->output->writeln( - '--> Hashed password will be stored (Hash shortened): ' . substr((string) $newPassword, 0, 10), + '--> Hashed password will be stored (Hash shortened): ' . substr((string)$newPassword, 0, 10), OutputInterface::VERBOSITY_DEBUG, ); return $newPassword; diff --git a/Classes/Controller/Ajax/SysFileController.php b/Classes/Controller/Ajax/SysFileController.php index d689654..a194d0d 100644 --- a/Classes/Controller/Ajax/SysFileController.php +++ b/Classes/Controller/Ajax/SysFileController.php @@ -33,7 +33,7 @@ class SysFileController { public function __construct( protected ResourceFactory $resourceFactory, - protected GraphicalFunctions $graphicalFunctions + protected GraphicalFunctions $graphicalFunctions, ) {} public function updateFileMetadataAction(ServerRequestInterface $request): JsonResponse @@ -111,8 +111,8 @@ protected function getValidatedFiles(ServerRequestInterface $request): array $validatedFiles = []; $files = $request->getQueryParams()['CB']['files'] ?? []; foreach ($files as $hash => $file) { - [$table, $hash] = explode('|', (string) $hash); - if ($table === '_FILE' && $hash === substr(md5((string) $file), 0, 10)) { + [$table, $hash] = explode('|', (string)$hash); + if ($table === '_FILE' && $hash === substr(md5((string)$file), 0, 10)) { $validatedFiles[] = $file; } } @@ -129,7 +129,7 @@ protected function determineImageMagickVersion(): string // A version like 6.9.10-23 $version = ''; if (isset($string) && $string !== '') { - [, $version] = explode('Magick', (string) $string); + [, $version] = explode('Magick', (string)$string); [$version] = explode(' ', trim($version)); [$version] = explode('-', trim($version)); $version = trim($version); diff --git a/Classes/Controller/SolrController.php b/Classes/Controller/SolrController.php index 4d86641..92c3114 100644 --- a/Classes/Controller/SolrController.php +++ b/Classes/Controller/SolrController.php @@ -41,7 +41,7 @@ class SolrController extends AbstractController public function __construct( protected readonly ModuleTemplateFactory $moduleTemplateFactory, - private readonly ConnectionPool $connectionPool + private readonly ConnectionPool $connectionPool, ) { parent::__construct($moduleTemplateFactory); } diff --git a/Classes/Database/Query/QueryGenerator.php b/Classes/Database/Query/QueryGenerator.php index f786a81..0775b42 100644 --- a/Classes/Database/Query/QueryGenerator.php +++ b/Classes/Database/Query/QueryGenerator.php @@ -22,9 +22,7 @@ */ class QueryGenerator { - public function __construct(private readonly ConnectionPool $connectionPool) - { - } + public function __construct(private readonly ConnectionPool $connectionPool) {} /** * @throws Exception diff --git a/Classes/EventListener/DisableRecordsWithInvalidFileMetaDataEventListener.php b/Classes/EventListener/DisableRecordsWithInvalidFileMetaDataEventListener.php index 856e284..2d1f9b3 100644 --- a/Classes/EventListener/DisableRecordsWithInvalidFileMetaDataEventListener.php +++ b/Classes/EventListener/DisableRecordsWithInvalidFileMetaDataEventListener.php @@ -96,7 +96,7 @@ private function disableReferencingRecords(File $file): void $this->registry->set( self::REGISTRY_NAMESPACE, $this->getRegistryKey($file->getUid()), - $disabledRecords + $disabledRecords, ); $this->flushPageCache(); } @@ -160,7 +160,7 @@ private function reEnableRecord(string $table, int $uid, File $file): bool $this->connectionPool->getConnectionForTable($table)->update( $table, [$enableColumn => 0], - ['uid' => $uid] + ['uid' => $uid], ); $this->addMessage( @@ -207,7 +207,7 @@ private function addMessage(string $table, int $uid, string $message): void SystemLogDatabaseAction::UPDATE, null, SystemLogErrorClassification::WARNING, - $message + $message, ); } @@ -248,7 +248,7 @@ private function isEnabled(): bool try { return (bool)$this->extensionConfiguration->get( 'jwtools2', - 'typo3DisableRecordsOnInvalidFileMetaData' + 'typo3DisableRecordsOnInvalidFileMetaData', ); } catch (ExtensionConfigurationExtensionNotConfiguredException|ExtensionConfigurationPathDoesNotExistException) { return false; @@ -258,7 +258,7 @@ private function isEnabled(): bool private function translate(string $key): string { return LocalizationUtility::translate( - 'LLL:EXT:jwtools2/Resources/Private/Language/locallang_mod.xlf:' . $key + 'LLL:EXT:jwtools2/Resources/Private/Language/locallang_mod.xlf:' . $key, ) ?? $key; } } diff --git a/Classes/EventListener/IndexServiceEventListener.php b/Classes/EventListener/IndexServiceEventListener.php index f62c26f..a855c69 100644 --- a/Classes/EventListener/IndexServiceEventListener.php +++ b/Classes/EventListener/IndexServiceEventListener.php @@ -16,8 +16,7 @@ readonly class IndexServiceEventListener { - public function __construct(private Registry $registry) - {} + public function __construct(private Registry $registry) {} public function __invoke(BeforeItemIsIndexedEvent $event): void { diff --git a/Classes/Hooks/CachingFrameworkLoggerHook.php b/Classes/Hooks/CachingFrameworkLoggerHook.php index 097e84e..d9fdd42 100644 --- a/Classes/Hooks/CachingFrameworkLoggerHook.php +++ b/Classes/Hooks/CachingFrameworkLoggerHook.php @@ -28,8 +28,7 @@ class CachingFrameworkLoggerHook implements LoggerAwareInterface use LoggerAwareTrait; use RequestArgumentsTrait; - public function __construct(private readonly ConnectionPool $connectionPool) - {} + public function __construct(private readonly ConnectionPool $connectionPool) {} /** * Analyze the data. If it matches create a new log entry diff --git a/Classes/Hooks/IndexService.php b/Classes/Hooks/IndexService.php index 11f52fb..19e2f93 100644 --- a/Classes/Hooks/IndexService.php +++ b/Classes/Hooks/IndexService.php @@ -17,8 +17,7 @@ readonly class IndexService { - public function __construct(private Registry $registry) - {} + public function __construct(private Registry $registry) {} /** * Save current Item ID in sys_registry for debugging diff --git a/Classes/Hooks/ModifyElementInformationHook.php b/Classes/Hooks/ModifyElementInformationHook.php index 57b61e9..21985f8 100644 --- a/Classes/Hooks/ModifyElementInformationHook.php +++ b/Classes/Hooks/ModifyElementInformationHook.php @@ -91,7 +91,7 @@ public function __construct( private readonly ResourceFactory $resourceFactory, private readonly RendererRegistry $rendererRegistry, private readonly ConnectionPool $connectionPool, - protected UriBuilder $uriBuilder + protected UriBuilder $uriBuilder, ) { $this->iconFactory = GeneralUtility::makeInstance(IconFactory::class); $this->uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); @@ -312,7 +312,7 @@ protected function getPropertiesForTable(): array $fieldList = $this->getFieldList($this->table, (int)($this->row['uid'] ?? 0)); foreach ($fieldList as $name) { - $name = trim((string) $name); + $name = trim((string)$name); $uid = $this->row['uid'] ?? 0; if (!isset($GLOBALS['TCA'][$this->table]['columns'][$name])) { diff --git a/Classes/Hooks/MoveTranslatedContentElementsHook.php b/Classes/Hooks/MoveTranslatedContentElementsHook.php index 43afbec..bb7c6cf 100644 --- a/Classes/Hooks/MoveTranslatedContentElementsHook.php +++ b/Classes/Hooks/MoveTranslatedContentElementsHook.php @@ -24,8 +24,7 @@ */ class MoveTranslatedContentElementsHook { - public function __construct(private readonly ConnectionPool $connectionPool) - {} + public function __construct(private readonly ConnectionPool $connectionPool) {} public function processDatamap_beforeStart(DataHandler $dataHandler): void { diff --git a/Classes/Hooks/ValidateFileMetaDataOnSaveHook.php b/Classes/Hooks/ValidateFileMetaDataOnSaveHook.php index 20d7543..a96f9e8 100644 --- a/Classes/Hooks/ValidateFileMetaDataOnSaveHook.php +++ b/Classes/Hooks/ValidateFileMetaDataOnSaveHook.php @@ -143,7 +143,7 @@ protected function isEnabled(): bool protected function translate(string $key): string { return LocalizationUtility::translate( - 'LLL:EXT:jwtools2/Resources/Private/Language/locallang_mod.xlf:' . $key + 'LLL:EXT:jwtools2/Resources/Private/Language/locallang_mod.xlf:' . $key, ) ?? $key; } } diff --git a/Classes/Service/FileMetaDataValidationService.php b/Classes/Service/FileMetaDataValidationService.php index 85b686c..56eb5bc 100644 --- a/Classes/Service/FileMetaDataValidationService.php +++ b/Classes/Service/FileMetaDataValidationService.php @@ -25,7 +25,7 @@ final class FileMetaDataValidationService public function __construct( private readonly ExtensionConfiguration $extensionConfiguration, - private readonly ConnectionPool $connectionPool + private readonly ConnectionPool $connectionPool, ) {} public function getRequiredColumns(): array diff --git a/Classes/Service/PlaceholderImageService.php b/Classes/Service/PlaceholderImageService.php index 829239e..bb111a9 100644 --- a/Classes/Service/PlaceholderImageService.php +++ b/Classes/Service/PlaceholderImageService.php @@ -33,11 +33,9 @@ final class PlaceholderImageService implements LoggerAwareInterface public function __construct( private readonly ExtensionConfiguration $extensionConfiguration, - private readonly RequestFactory $requestFactory, - private readonly ResourceFactory $resourceFactory, - ) - { - } + private readonly RequestFactory $requestFactory, + private readonly ResourceFactory $resourceFactory, + ) {} public function getPlaceholderFile(int $width, int $height): ?File { diff --git a/Classes/Service/SolrService.php b/Classes/Service/SolrService.php index c246a3f..0d5351e 100644 --- a/Classes/Service/SolrService.php +++ b/Classes/Service/SolrService.php @@ -23,8 +23,7 @@ */ class SolrService { - public function __construct(private readonly ConnectionPool $connectionPool) - {} + public function __construct(private readonly ConnectionPool $connectionPool) {} /** * Instead of the Solr Statistic, this Statistic will return diff --git a/Classes/Task/ExecuteQueryTask.php b/Classes/Task/ExecuteQueryTask.php index 8fcc31d..d40123f 100644 --- a/Classes/Task/ExecuteQueryTask.php +++ b/Classes/Task/ExecuteQueryTask.php @@ -27,9 +27,8 @@ class ExecuteQueryTask extends AbstractTask public function __construct( private readonly FlashMessageService $flashMessageService, - private readonly ConnectionPool $connectionPool - ) - { + private readonly ConnectionPool $connectionPool, + ) { parent::__construct(); } diff --git a/Configuration/JavaScriptModules.php b/Configuration/JavaScriptModules.php index cd53c79..d064818 100644 --- a/Configuration/JavaScriptModules.php +++ b/Configuration/JavaScriptModules.php @@ -8,6 +8,7 @@ * For the full copyright and license information, please read the * LICENSE file that was distributed with this source code. */ + return [ 'dependencies' => [ 'backend', 'core', diff --git a/Configuration/TCA/tx_jwtools2_cache_expression.php b/Configuration/TCA/tx_jwtools2_cache_expression.php index fff207b..8b18b77 100755 --- a/Configuration/TCA/tx_jwtools2_cache_expression.php +++ b/Configuration/TCA/tx_jwtools2_cache_expression.php @@ -8,6 +8,7 @@ * For the full copyright and license information, please read the * LICENSE file that was distributed with this source code. */ + return [ 'ctrl' => [ 'title' => 'Cache Expressions', diff --git a/ext_emconf.php b/ext_emconf.php index 9fa1230..6934cf0 100644 --- a/ext_emconf.php +++ b/ext_emconf.php @@ -8,6 +8,7 @@ * For the full copyright and license information, please read the * LICENSE file that was distributed with this source code. */ + $EM_CONF[$_EXTKEY] = [ 'title' => 'JW tools', 'description' => 'Jwtools2 contains a scheduler task for Solr to index multiple Pagetrees and a task to execute SQL-Queries. Further there are settings to enable some features in TYPO3 like showing the Page UID in Pagetree with a simple click in extensionmanager.', From 435c6d4f7c018b6421afc7363c5eb7f4b9349432 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 16:19:53 +0200 Subject: [PATCH 49/52] [TASK] Add GitHub Actions workflow to automate TER releases for TYPO3 extension --- .github/workflows/ter-release.yml | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/ter-release.yml diff --git a/.github/workflows/ter-release.yml b/.github/workflows/ter-release.yml new file mode 100644 index 0000000..76c2e89 --- /dev/null +++ b/.github/workflows/ter-release.yml @@ -0,0 +1,56 @@ +name: ter-release.yml + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + + publish: + name: Publish Extension to TYPO3 Extension Repository (TER) + runs-on: ubuntu-latest + + env: + TYPO3_EXTENSION_KEY: ${{ secrets.TYPO3_EXTENSION_KEY }} + TYPO3_REPOSITORY_URL: ${{ secrets.TYPO3_REPOSITORY_URL }} + TYPO3_API_TOKEN: ${{ secrets.TYPO3_API_TOKEN }} + TYPO3_API_USERNAME: ${{ secrets.TYPO3_API_USERNAME }} + TYPO3_API_PASSWORD: ${{ secrets.TYPO3_API_PASSWORD }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Get version and description + id: prep + run: | + # 1. Clean the version tag (removes 'v' prefix if present, e.g., v1.0.0 -> 1.0.0) + RAW_VERSION="${{ github.event.release.tag_name }}" + VERSION=${RAW_VERSION#v} + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + + # 2. Safely capture the multi-line release body as an ENV variable + echo "RELEASE_NOTES<> $GITHUB_ENV + echo "${{ github.event.release.body }}" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: intl, mbstring, json, libxml, xml, zip, curl + tools: composer:v2 + + - name: Install TYPO3 Tailor Extension + run: composer global require typo3/tailor --prefer-dist --no-progress --no-suggest + + - name: Release to TER + run: | + # Use the VERSION from steps and RELEASE_NOTES from env + # We use double quotes around env.RELEASE_NOTES to handle the multi-line content + php ~/.composer/vendor/bin/tailor ter:publish ${{ steps.prep.outputs.VERSION }} \ + --artefact=${{ env.TYPO3_REPOSITORY_URL }}/archive/${{ github.event.release.tag_name }}.zip \ + --comment="${{ env.RELEASE_NOTES }}" From f6c4baf55ba73b9c4eb8ad2acf53f9b46e0ee32f Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 16:20:58 +0200 Subject: [PATCH 50/52] [TASK] Adjust class definitions and improve metadata validation logic - Removed `final` keyword from multiple service classes to enhance extensibility. - Merged metadata properties with file properties in `FileMetaDataValidationService` for comprehensive validation. - Updated `Services.yaml` to include task definitions for scheduler module support. --- Classes/Service/FileMetaDataValidationService.php | 8 ++++++-- Classes/Service/FileReferenceResolverService.php | 2 +- Classes/Service/PlaceholderImageService.php | 2 +- Configuration/Services.yaml | 8 ++++++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Classes/Service/FileMetaDataValidationService.php b/Classes/Service/FileMetaDataValidationService.php index 56eb5bc..6be2f9f 100644 --- a/Classes/Service/FileMetaDataValidationService.php +++ b/Classes/Service/FileMetaDataValidationService.php @@ -19,7 +19,7 @@ use TYPO3\CMS\Core\Resource\FileType; use TYPO3\CMS\Core\Utility\GeneralUtility; -final class FileMetaDataValidationService +class FileMetaDataValidationService { private ?array $requiredColumnsCache = null; @@ -51,7 +51,11 @@ public function getMissingColumns(File $file): array } $missingColumns = []; - $properties = $file->getProperties(); + // Most enforceable columns (e.g. "creator", "copyright") live exclusively in + // sys_file_metadata and are never part of File::getProperties() (which only reflects + // the sys_file row). Merge in the metadata aspect so both kinds of columns are covered; + // metadata wins on overlap since it is the authoritative source for these fields. + $properties = array_merge($file->getProperties(), $file->getMetaData()->get()); foreach ($this->getRequiredColumns() as $requiredColumn) { // Do not use isset() as "null" values have to be tested, too. diff --git a/Classes/Service/FileReferenceResolverService.php b/Classes/Service/FileReferenceResolverService.php index 200c28b..4db5c4b 100644 --- a/Classes/Service/FileReferenceResolverService.php +++ b/Classes/Service/FileReferenceResolverService.php @@ -17,7 +17,7 @@ use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; use TYPO3\CMS\Core\Utility\GeneralUtility; -final readonly class FileReferenceResolverService +readonly class FileReferenceResolverService { public function __construct(private ConnectionPool $connectionPool) {} diff --git a/Classes/Service/PlaceholderImageService.php b/Classes/Service/PlaceholderImageService.php index bb111a9..3efe597 100644 --- a/Classes/Service/PlaceholderImageService.php +++ b/Classes/Service/PlaceholderImageService.php @@ -23,7 +23,7 @@ use TYPO3\CMS\Core\Resource\ResourceFactory; use TYPO3\CMS\Core\Utility\GeneralUtility; -final class PlaceholderImageService implements LoggerAwareInterface +class PlaceholderImageService implements LoggerAwareInterface { use LoggerAwareTrait; diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index 9c546b5..f2bc583 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -47,6 +47,14 @@ services: JWeiland\Jwtools2\Hooks\ValidateFileMetaDataOnSaveHook: public: true + # Called by makeInstance in saveTask()/editTaskAction() of SchedulerModuleController + JWeiland\Jwtools2\Task\ExecuteQueryTask: + public: true + + # Called by makeInstance in saveTask()/editTaskAction() of SchedulerModuleController + JWeiland\Jwtools2\Task\IndexQueueWorkerTask: + public: true + JWeiland\Jwtools2\LinkHandler\FileLinkHandler: public: true From 437f64d74c25dd49b7380170f0d479d706607077 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 16:21:05 +0200 Subject: [PATCH 51/52] [TASK] Refactor ExecuteQueryTaskTest for improved dependency injection and test coverage - Migrated `ExecuteQueryTask` to use constructor injection for `FlashMessageService` and `ConnectionPool`. - Refactored test setup to mock dependencies directly through the constructor. - Added new test cases to improve coverage for scenarios like no queries and failing statements. - Removed outdated use of `GeneralUtility::addInstance`. --- Tests/Unit/Task/ExecuteQueryTaskTest.php | 108 ++++++++++++++++++----- 1 file changed, 85 insertions(+), 23 deletions(-) diff --git a/Tests/Unit/Task/ExecuteQueryTaskTest.php b/Tests/Unit/Task/ExecuteQueryTaskTest.php index c279ef6..c28620f 100644 --- a/Tests/Unit/Task/ExecuteQueryTaskTest.php +++ b/Tests/Unit/Task/ExecuteQueryTaskTest.php @@ -11,18 +11,23 @@ namespace JWeiland\Jwtools2\Tests\Unit\Task; -use Doctrine\DBAL\Driver\Statement; use JWeiland\Jwtools2\Task\ExecuteQueryTask; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\MockObject\MockObject; use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Messaging\FlashMessageQueue; +use TYPO3\CMS\Core\Messaging\FlashMessageService; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Scheduler\Scheduler; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; class ExecuteQueryTaskTest extends UnitTestCase { - protected ExecuteQueryTask $subject; + /** + * @var FlashMessageService|MockObject + */ + protected $flashMessageServiceMock; protected function setUp(): void { @@ -48,25 +53,47 @@ protected function setUp(): void // Mock the Scheduler class GeneralUtility::setSingletonInstance(Scheduler::class, $this->createMock(Scheduler::class)); - $this->subject = new ExecuteQueryTask(); + // execute() always ends up calling addMessage(), which pulls a FlashMessageQueue + // from the injected FlashMessageService and enqueues into it - stub that chain + // once here so every test path (success, no queries, exception) works without + // repeating this in each test. + $this->flashMessageServiceMock = $this->createMock(FlashMessageService::class); + $this->flashMessageServiceMock + ->method('getMessageQueueByIdentifier') + ->willReturn($this->createMock(FlashMessageQueue::class)); } - #[Test] - public function executeWithSingleQueryWillReturnTrue(): void + protected function tearDown(): void { - $this->subject->setSqlQuery('UPDATE what_ever;'); + unset($this->flashMessageServiceMock); - $statementMock = $this->getMockBuilder(Statement::class) - ->disableOriginalConstructor() - ->getMock(); + parent::tearDown(); + } + + /** + * ExecuteQueryTask now receives its FlashMessageService and ConnectionPool via + * constructor injection (it is instantiated through the DI container / GeneralUtility:: + * makeInstance() at runtime, see the "public: true" entry in Services.yaml). Since the + * ConnectionPool is a real constructor dependency now - not fetched lazily via + * GeneralUtility::makeInstance() inside execute() anymore - tests build the subject with + * a per-test-configured ConnectionPool mock directly, instead of the previous + * GeneralUtility::addInstance() approach, which no longer has any effect on this class. + */ + protected function createSubject(ConnectionPool $connectionPool): ExecuteQueryTask + { + return new ExecuteQueryTask( + $this->flashMessageServiceMock, + $connectionPool, + ); + } + #[Test] + public function executeWithSingleQueryWillReturnTrue(): void + { $connectionMock = $this->createMock(Connection::class); $connectionMock->expects(self::once()) ->method('executeStatement') - ->with( - self::equalTo('UPDATE what_ever;'), - self::equalTo([]), - ) + ->with(self::equalTo('UPDATE what_ever;')) ->willReturn(1); $connectionPoolMock = $this->createMock(ConnectionPool::class); @@ -75,22 +102,17 @@ public function executeWithSingleQueryWillReturnTrue(): void ->with('Default') ->willReturn($connectionMock); - GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolMock); + $subject = $this->createSubject($connectionPoolMock); + $subject->setSqlQuery('UPDATE what_ever;'); self::assertTrue( - $this->subject->execute(), + $subject->execute(), ); } #[Test] public function executeWithMultipleQueriesWillReturnTrue(): void { - $this->subject->setSqlQuery("UPDATE what_ever;\nUPDATE that;\nUPDATE else;"); - - $statementMock = $this->getMockBuilder(Statement::class) - ->disableOriginalConstructor() - ->getMock(); - $connectionMock = $this->createMock(Connection::class); $connectionMock->expects(self::exactly(3)) ->method('executeStatement') @@ -106,10 +128,50 @@ public function executeWithMultipleQueriesWillReturnTrue(): void ->with('Default') ->willReturn($connectionMock); - GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolMock); + $subject = $this->createSubject($connectionPoolMock); + $subject->setSqlQuery("UPDATE what_ever;\nUPDATE that;\nUPDATE else;"); self::assertTrue( - $this->subject->execute(), + $subject->execute(), + ); + } + + #[Test] + public function executeWithNoQueriesWillReturnFalse(): void + { + $connectionPoolMock = $this->createMock(ConnectionPool::class); + $connectionPoolMock->expects(self::once()) + ->method('getConnectionByName') + ->with('Default') + ->willReturn($this->createMock(Connection::class)); + + $subject = $this->createSubject($connectionPoolMock); + $subject->setSqlQuery(''); + + self::assertFalse( + $subject->execute(), + ); + } + + #[Test] + public function executeWithFailingStatementWillReturnFalse(): void + { + $connectionMock = $this->createMock(Connection::class); + $connectionMock->expects(self::once()) + ->method('executeStatement') + ->willThrowException(new \RuntimeException('broken query')); + + $connectionPoolMock = $this->createMock(ConnectionPool::class); + $connectionPoolMock->expects(self::once()) + ->method('getConnectionByName') + ->with('Default') + ->willReturn($connectionMock); + + $subject = $this->createSubject($connectionPoolMock); + $subject->setSqlQuery('UPDATE broken;'); + + self::assertFalse( + $subject->execute(), ); } } From 5408c260fe94e8353de19f0321b4125be7f6db09 Mon Sep 17 00:00:00 2001 From: Hoja Mustaffa Abdul Latheef Date: Mon, 3 Aug 2026 16:21:51 +0200 Subject: [PATCH 52/52] [TASK] Add unit tests for FileMetaDataController, FileMetaDataValidationService, and PlaceholderImageService - Introduced unit tests for `FileMetaDataController` to validate helper methods like `getStatus`, `getReferenceScope`, and `getFilters`. - Added comprehensive tests for `FileMetaDataValidationService` to ensure robust metadata validation logic. - Implemented tests for `PlaceholderImageService` to verify placeholder image generation, caching, and API integration. --- .../Controller/FileMetaDataControllerTest.php | 201 +++++++ ...eInvalidMetaDataImageEventListenerTest.php | 324 +++++++++++ .../FileMetaDataValidationServiceTest.php | 346 +++++++++++ .../Service/PlaceholderImageServiceTest.php | 545 ++++++++++++++++++ 4 files changed, 1416 insertions(+) create mode 100644 Tests/Unit/Controller/FileMetaDataControllerTest.php create mode 100644 Tests/Unit/EventListener/ReplaceInvalidMetaDataImageEventListenerTest.php create mode 100644 Tests/Unit/Service/FileMetaDataValidationServiceTest.php create mode 100644 Tests/Unit/Service/PlaceholderImageServiceTest.php diff --git a/Tests/Unit/Controller/FileMetaDataControllerTest.php b/Tests/Unit/Controller/FileMetaDataControllerTest.php new file mode 100644 index 0000000..8c0fcba --- /dev/null +++ b/Tests/Unit/Controller/FileMetaDataControllerTest.php @@ -0,0 +1,201 @@ +newInstanceWithoutConstructor(); + + $this->subject = new FileMetaDataController( + $moduleTemplateFactory, + $this->createMock(FileMetaDataValidationService::class), + $this->createMock(FileReferenceResolverService::class), + $this->createMock(ConnectionPool::class), + $this->createMock(ResourceFactory::class), + ); + } + + protected function tearDown(): void + { + unset($this->subject); + + parent::tearDown(); + } + + protected function invokeProtectedMethod(string $method, array $arguments = []): mixed + { + $reflectionMethod = new \ReflectionMethod($this->subject, $method); + $reflectionMethod->setAccessible(true); + + return $reflectionMethod->invokeArgs($this->subject, $arguments); + } + + // -- getStatus() ------------------------------------------------------------------- + + #[Test] + public function getStatusReturnsValidWhenNoColumnsAreMissing(): void + { + self::assertSame( + 'valid', + $this->invokeProtectedMethod('getStatus', [[], ['creator', 'copyright']]), + ); + } + + #[Test] + public function getStatusReturnsValidWhenNoColumnsAreRequiredAtAll(): void + { + self::assertSame( + 'valid', + $this->invokeProtectedMethod('getStatus', [[], []]), + ); + } + + #[Test] + public function getStatusReturnsMissingWhenAllRequiredColumnsAreMissing(): void + { + self::assertSame( + 'missing', + $this->invokeProtectedMethod('getStatus', [['creator', 'copyright'], ['creator', 'copyright']]), + ); + } + + #[Test] + public function getStatusReturnsPartialWhenSomeButNotAllColumnsAreMissing(): void + { + self::assertSame( + 'partial', + $this->invokeProtectedMethod('getStatus', [['copyright'], ['creator', 'copyright']]), + ); + } + + // -- getReferenceScope() ----------------------------------------------------------- + + #[Test] + public function getReferenceScopeReturnsOrphanedWhenThereAreNoReferences(): void + { + self::assertSame( + 'orphaned', + $this->invokeProtectedMethod('getReferenceScope', [[]]), + ); + } + + #[Test] + public function getReferenceScopeReturnsTtContentWhenOnlyReferencedByContentElements(): void + { + $references = [ + ['tablename' => 'tt_content', 'recuid' => 1, 'field' => 'image'], + ['tablename' => 'tt_content', 'recuid' => 2, 'field' => 'image'], + ]; + + self::assertSame( + 'tt_content', + $this->invokeProtectedMethod('getReferenceScope', [$references]), + ); + } + + #[Test] + public function getReferenceScopeReturnsOtherWhenOnlyReferencedByNonContentElementRecords(): void + { + $references = [ + ['tablename' => 'tx_news_domain_model_news', 'recuid' => 1, 'field' => 'image'], + ]; + + self::assertSame( + 'other', + $this->invokeProtectedMethod('getReferenceScope', [$references]), + ); + } + + #[Test] + public function getReferenceScopeReturnsBothWhenReferencedByContentElementsAndOtherRecords(): void + { + $references = [ + ['tablename' => 'tt_content', 'recuid' => 1, 'field' => 'image'], + ['tablename' => 'tx_news_domain_model_news', 'recuid' => 2, 'field' => 'image'], + ]; + + self::assertSame( + 'both', + $this->invokeProtectedMethod('getReferenceScope', [$references]), + ); + } + + // -- getFilters() ------------------------------------------------------------------- + + #[Test] + public function getFiltersReturnsAllDefaultsForEmptyInput(): void + { + self::assertSame( + [ + 'status' => 'all', + 'missingColumn' => '', + 'referenceScope' => 'all', + 'storage' => '', + 'extension' => '', + 'search' => '', + 'page' => '1', + ], + $this->invokeProtectedMethod('getFilters', [[]]), + ); + } + + #[Test] + public function getFiltersKeepsProvidedValuesAndTrimsSearch(): void + { + self::assertSame( + [ + 'status' => 'missing', + 'missingColumn' => 'creator', + 'referenceScope' => 'orphaned', + 'storage' => '1', + 'extension' => 'jpg', + 'search' => 'forest', + 'page' => '3', + ], + $this->invokeProtectedMethod('getFilters', [[ + 'status' => 'missing', + 'missingColumn' => 'creator', + 'referenceScope' => 'orphaned', + 'storage' => '1', + 'extension' => 'jpg', + 'search' => ' forest ', + 'page' => '3', + ]]), + ); + } +} diff --git a/Tests/Unit/EventListener/ReplaceInvalidMetaDataImageEventListenerTest.php b/Tests/Unit/EventListener/ReplaceInvalidMetaDataImageEventListenerTest.php new file mode 100644 index 0000000..b672085 --- /dev/null +++ b/Tests/Unit/EventListener/ReplaceInvalidMetaDataImageEventListenerTest.php @@ -0,0 +1,324 @@ +extensionConfigurationMock = $this->createMock(ExtensionConfiguration::class); + $this->fileMetaDataValidationServiceMock = $this->createMock(FileMetaDataValidationService::class); + $this->placeholderImageServiceMock = $this->createMock(PlaceholderImageService::class); + + $this->subject = new ReplaceInvalidMetaDataImageEventListener( + $this->extensionConfigurationMock, + $this->fileMetaDataValidationServiceMock, + $this->placeholderImageServiceMock, + ); + } + + protected function tearDown(): void + { + unset( + $GLOBALS['TYPO3_REQUEST'], + $this->extensionConfigurationMock, + $this->fileMetaDataValidationServiceMock, + $this->placeholderImageServiceMock, + $this->subject, + ); + + parent::tearDown(); + } + + protected function enable(): void + { + $this->extensionConfigurationMock + ->method('get') + ->with('jwtools2', 'typo3ReplaceFrontendImagesWithInvalidMetaData') + ->willReturn(true); + } + + protected function disable(): void + { + $this->extensionConfigurationMock + ->method('get') + ->with('jwtools2', 'typo3ReplaceFrontendImagesWithInvalidMetaData') + ->willReturn(false); + } + + protected function setFrontendRequest(): void + { + $GLOBALS['TYPO3_REQUEST'] = (new ServerRequest()) + ->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_FE); + } + + protected function setBackendRequest(): void + { + $GLOBALS['TYPO3_REQUEST'] = (new ServerRequest()) + ->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE); + } + + /** + * @param array $configuration + */ + protected function createEvent(File $file, array $configuration = [], string $taskType = 'Image.CropScaleMask'): BeforeFileProcessingEvent + { + return new BeforeFileProcessingEvent( + $this->createMock(DriverInterface::class), + $this->createMock(ProcessedFile::class), + $file, + $taskType, + $configuration, + ); + } + + protected function createImageFile(string $name = 'broken-image.jpg'): File&MockObject + { + $file = $this->createMock(File::class); + $file->method('getType')->willReturn(FileType::IMAGE->value); + $file->method('getName')->willReturn($name); + + return $file; + } + + #[Test] + public function invokeDoesNothingWhenFeatureIsDisabled(): void + { + $this->disable(); + $this->setFrontendRequest(); + + $this->fileMetaDataValidationServiceMock->expects(self::never())->method('hasValidMetaData'); + $this->placeholderImageServiceMock->expects(self::never())->method('getPlaceholderFile'); + + $event = $this->createEvent($this->createImageFile()); + $initialProcessedFile = $event->getProcessedFile(); + + ($this->subject)($event); + + self::assertSame($initialProcessedFile, $event->getProcessedFile()); + } + + #[Test] + public function invokeDoesNothingOnBackendRequests(): void + { + $this->enable(); + $this->setBackendRequest(); + + $this->fileMetaDataValidationServiceMock->expects(self::never())->method('hasValidMetaData'); + + $event = $this->createEvent($this->createImageFile()); + $initialProcessedFile = $event->getProcessedFile(); + + ($this->subject)($event); + + self::assertSame($initialProcessedFile, $event->getProcessedFile()); + } + + #[Test] + public function invokeDoesNothingWhenNoRequestIsAvailableAtAll(): void + { + $this->enable(); + unset($GLOBALS['TYPO3_REQUEST']); + + $this->fileMetaDataValidationServiceMock->expects(self::never())->method('hasValidMetaData'); + + $event = $this->createEvent($this->createImageFile()); + $initialProcessedFile = $event->getProcessedFile(); + + ($this->subject)($event); + + self::assertSame($initialProcessedFile, $event->getProcessedFile()); + } + + #[Test] + public function invokeDoesNothingForNonImageFiles(): void + { + $this->enable(); + $this->setFrontendRequest(); + + $file = $this->createMock(File::class); + $file->method('getType')->willReturn(FileType::TEXT->value); + + $this->fileMetaDataValidationServiceMock->expects(self::never())->method('hasValidMetaData'); + + $event = $this->createEvent($file); + $initialProcessedFile = $event->getProcessedFile(); + + ($this->subject)($event); + + self::assertSame($initialProcessedFile, $event->getProcessedFile()); + } + + #[Test] + public function invokeDoesNothingForThePlaceholderFileItselfToAvoidRecursion(): void + { + $this->enable(); + $this->setFrontendRequest(); + + $file = $this->createImageFile('placeholder_300x300.png'); + + $this->fileMetaDataValidationServiceMock->expects(self::never())->method('hasValidMetaData'); + + $event = $this->createEvent($file); + $initialProcessedFile = $event->getProcessedFile(); + + ($this->subject)($event); + + self::assertSame($initialProcessedFile, $event->getProcessedFile()); + } + + #[Test] + public function invokeDoesNothingWhenMetaDataIsAlreadyValid(): void + { + $this->enable(); + $this->setFrontendRequest(); + + $file = $this->createImageFile(); + $this->fileMetaDataValidationServiceMock->method('hasValidMetaData')->with($file)->willReturn(true); + + $this->placeholderImageServiceMock->expects(self::never())->method('getPlaceholderFile'); + + $event = $this->createEvent($file); + $initialProcessedFile = $event->getProcessedFile(); + + ($this->subject)($event); + + self::assertSame($initialProcessedFile, $event->getProcessedFile()); + } + + #[Test] + public function invokeDoesNothingWhenNoPlaceholderCouldBeBuilt(): void + { + $this->enable(); + $this->setFrontendRequest(); + + $file = $this->createImageFile(); + $this->fileMetaDataValidationServiceMock->method('hasValidMetaData')->willReturn(false); + $this->placeholderImageServiceMock->method('getPlaceholderFile')->willReturn(null); + + $event = $this->createEvent($file); + $initialProcessedFile = $event->getProcessedFile(); + + ($this->subject)($event); + + self::assertSame($initialProcessedFile, $event->getProcessedFile()); + } + + #[Test] + public function invokeSetsProcessedFileFromPlaceholderWhenMetaDataIsInvalid(): void + { + $this->enable(); + $this->setFrontendRequest(); + + $file = $this->createImageFile(); + $this->fileMetaDataValidationServiceMock->method('hasValidMetaData')->willReturn(false); + + $configuration = ['width' => '300m', 'height' => '150c']; + + $placeholderFileMock = $this->createMock(File::class); + $this->placeholderImageServiceMock + ->expects(self::once()) + ->method('getPlaceholderFile') + ->with(300, 150) + ->willReturn($placeholderFileMock); + + $newProcessedFileMock = $this->createMock(ProcessedFile::class); + $placeholderFileMock->expects(self::once()) + ->method('process') + ->with('Image.CropScaleMask', $configuration) + ->willReturn($newProcessedFileMock); + + $event = $this->createEvent($file, $configuration); + + ($this->subject)($event); + + self::assertSame($newProcessedFileMock, $event->getProcessedFile()); + } + + #[Test] + public function invokeUsesDefaultDimensionsWhenConfigurationHasNone(): void + { + $this->enable(); + $this->setFrontendRequest(); + + $file = $this->createImageFile(); + $this->fileMetaDataValidationServiceMock->method('hasValidMetaData')->willReturn(false); + + $placeholderFileMock = $this->createMock(File::class); + $this->placeholderImageServiceMock + ->expects(self::once()) + ->method('getPlaceholderFile') + ->with(300, 300) + ->willReturn($placeholderFileMock); + + $placeholderFileMock->method('process')->willReturn($this->createMock(ProcessedFile::class)); + + $event = $this->createEvent($file, []); + + ($this->subject)($event); + } + + #[Test] + public function invokeFailsOpenWhenProcessingThePlaceholderThrows(): void + { + $this->enable(); + $this->setFrontendRequest(); + + $file = $this->createImageFile(); + $this->fileMetaDataValidationServiceMock->method('hasValidMetaData')->willReturn(false); + + $placeholderFileMock = $this->createMock(File::class); + $this->placeholderImageServiceMock->method('getPlaceholderFile')->willReturn($placeholderFileMock); + $placeholderFileMock->method('process')->willThrowException(new \RuntimeException('processor unavailable')); + + $event = $this->createEvent($file); + $initialProcessedFile = $event->getProcessedFile(); + + ($this->subject)($event); + + self::assertSame($initialProcessedFile, $event->getProcessedFile()); + } +} diff --git a/Tests/Unit/Service/FileMetaDataValidationServiceTest.php b/Tests/Unit/Service/FileMetaDataValidationServiceTest.php new file mode 100644 index 0000000..67572a6 --- /dev/null +++ b/Tests/Unit/Service/FileMetaDataValidationServiceTest.php @@ -0,0 +1,346 @@ +extensionConfigurationMock = $this->createMock(ExtensionConfiguration::class); + + // isValidColumn() introspects sys_file first and, if the column is not found there, + // falls back to sys_file_metadata. Both tables are backed by the same mocked + // connection/schema manager here; only the two Table doubles differ in which + // columns they report as existing. + $this->sysFileTableMock = $this->createMock(Table::class); + $this->sysFileMetadataTableMock = $this->createMock(Table::class); + + $schemaManagerMock = $this->createMock(AbstractSchemaManager::class); + $schemaManagerMock->method('tablesExist')->willReturn(true); + $schemaManagerMock->method('introspectTable')->willReturnCallback( + fn(string $table) => $table === 'sys_file_metadata' ? $this->sysFileMetadataTableMock : $this->sysFileTableMock, + ); + + $connectionMock = $this->createMock(Connection::class); + $connectionMock->method('createSchemaManager')->willReturn($schemaManagerMock); + + $this->connectionPoolMock = $this->createMock(ConnectionPool::class); + $this->connectionPoolMock->method('getConnectionForTable')->willReturn($connectionMock); + + $this->storageMock = $this->createMock(ResourceStorage::class); + + $this->subject = new FileMetaDataValidationService( + $this->extensionConfigurationMock, + $this->connectionPoolMock, + ); + } + + protected function tearDown(): void + { + unset( + $this->subject, + $this->extensionConfigurationMock, + $this->connectionPoolMock, + $this->sysFileTableMock, + $this->sysFileMetadataTableMock, + $this->storageMock, + ); + + parent::tearDown(); + } + + protected function configureRequiredColumnsConfiguration(string $value): void + { + $this->extensionConfigurationMock + ->method('get') + ->with('jwtools2', 'typo3RequiredColumnsForFiles') + ->willReturn($value); + } + + /** + * @param array $existingColumns + */ + protected function makeColumnsExistOnSysFileMetadata(array $existingColumns): void + { + $this->sysFileTableMock->method('hasColumn')->willReturn(false); + $this->sysFileMetadataTableMock->method('hasColumn')->willReturnCallback( + static fn(string $column) => in_array($column, $existingColumns, true), + ); + } + + /** + * @param array $metaData + */ + protected function createImageFile(array $metaData): File + { + $file = new File( + [ + 'identifier' => '/foo.jpg', + 'name' => 'foo.jpg', + 'type' => FileType::IMAGE->value, + ], + $this->storageMock, + ); + + // File's constructor only forwards $metaData to MetaDataAspect::add() when it is + // non-empty, so an empty array here would leave the aspect "not loaded" and any + // later ->get() call would try to lazy-load from the (unavailable) MetaDataRepository. + // Calling add() directly marks it as loaded regardless of emptiness. + $file->getMetaData()->add($metaData); + + return $file; + } + + #[Test] + public function getRequiredColumnsReturnsEmptyArrayWhenConfigurationIsEmpty(): void + { + $this->configureRequiredColumnsConfiguration(''); + + self::assertSame( + [], + $this->subject->getRequiredColumns(), + ); + } + + #[Test] + public function getRequiredColumnsFiltersOutColumnsThatDoNotExistInTheDatabase(): void + { + $this->configureRequiredColumnsConfiguration('creator, copyright ,this_column_does_not_exist'); + $this->makeColumnsExistOnSysFileMetadata(['creator', 'copyright']); + + self::assertSame( + ['creator', 'copyright'], + $this->subject->getRequiredColumns(), + ); + } + + #[Test] + public function getRequiredColumnsResultIsCachedAfterFirstCall(): void + { + // Dedicated, strictly-counted mocks: isValidColumn() checks "sys_file" first and + // falls back to "sys_file_metadata", so a single required column causes exactly + // one introspectTable() call per table. If the result were not cached, calling + // getRequiredColumns() twice would double that to 4 - so exactly(2) proves caching. + $sysFileTableMock = $this->createMock(Table::class); + $sysFileTableMock->method('hasColumn')->willReturn(false); + + $sysFileMetadataTableMock = $this->createMock(Table::class); + $sysFileMetadataTableMock->method('hasColumn')->willReturn(true); + + $schemaManagerMock = $this->createMock(AbstractSchemaManager::class); + $schemaManagerMock->method('tablesExist')->willReturn(true); + $schemaManagerMock->expects(self::exactly(2)) + ->method('introspectTable') + ->willReturnCallback( + fn(string $table) => $table === 'sys_file_metadata' ? $sysFileMetadataTableMock : $sysFileTableMock, + ); + + $connectionMock = $this->createMock(Connection::class); + $connectionMock->method('createSchemaManager')->willReturn($schemaManagerMock); + + $connectionPoolMock = $this->createMock(ConnectionPool::class); + $connectionPoolMock->method('getConnectionForTable')->willReturn($connectionMock); + + $this->configureRequiredColumnsConfiguration('creator'); + $subject = new FileMetaDataValidationService($this->extensionConfigurationMock, $connectionPoolMock); + + self::assertSame(['creator'], $subject->getRequiredColumns()); + self::assertSame(['creator'], $subject->getRequiredColumns()); + } + + #[Test] + public function getMissingColumnsReturnsEmptyArrayWhenNoColumnsAreRequired(): void + { + $this->configureRequiredColumnsConfiguration(''); + $file = $this->createImageFile(['creator' => 'John Doe']); + + self::assertSame( + [], + $this->subject->getMissingColumns($file), + ); + } + + #[Test] + public function getMissingColumnsReturnsEmptyArrayForNonImageFile(): void + { + $this->configureRequiredColumnsConfiguration('creator,copyright'); + $this->makeColumnsExistOnSysFileMetadata(['creator', 'copyright']); + + $file = new File( + [ + 'identifier' => '/foo.pdf', + 'name' => 'foo.pdf', + 'type' => FileType::TEXT->value, + ], + $this->storageMock, + [], + ); + + self::assertSame( + [], + $this->subject->getMissingColumns($file), + ); + } + + #[Test] + public function getMissingColumnsDetectsColumnThatIsNotSetAtAll(): void + { + $this->configureRequiredColumnsConfiguration('creator,copyright'); + $this->makeColumnsExistOnSysFileMetadata(['creator', 'copyright']); + + $file = $this->createImageFile(['creator' => 'John Doe']); + + self::assertSame( + ['copyright'], + $this->subject->getMissingColumns($file), + ); + } + + #[Test] + public function getMissingColumnsDetectsEmptyStringAsMissing(): void + { + $this->configureRequiredColumnsConfiguration('creator'); + $this->makeColumnsExistOnSysFileMetadata(['creator']); + + $file = $this->createImageFile(['creator' => '']); + + self::assertSame( + ['creator'], + $this->subject->getMissingColumns($file), + ); + } + + #[Test] + public function getMissingColumnsDetectsWhitespaceOnlyStringAsMissing(): void + { + $this->configureRequiredColumnsConfiguration('creator'); + $this->makeColumnsExistOnSysFileMetadata(['creator']); + + $file = $this->createImageFile(['creator' => ' ']); + + self::assertSame( + ['creator'], + $this->subject->getMissingColumns($file), + ); + } + + #[Test] + public function getMissingColumnsDetectsNullAsMissing(): void + { + $this->configureRequiredColumnsConfiguration('creator'); + $this->makeColumnsExistOnSysFileMetadata(['creator']); + + $file = $this->createImageFile(['creator' => null]); + + self::assertSame( + ['creator'], + $this->subject->getMissingColumns($file), + ); + } + + #[Test] + public function getMissingColumnsReturnsEmptyArrayWhenAllRequiredColumnsAreFilled(): void + { + $this->configureRequiredColumnsConfiguration('creator,copyright'); + $this->makeColumnsExistOnSysFileMetadata(['creator', 'copyright']); + + $file = $this->createImageFile([ + 'creator' => 'John Doe', + 'copyright' => '(c) City of Pforzheim', + ]); + + self::assertSame( + [], + $this->subject->getMissingColumns($file), + ); + } + + #[Test] + public function hasValidMetaDataReturnsFalseWhenColumnsAreMissing(): void + { + $this->configureRequiredColumnsConfiguration('creator'); + $this->makeColumnsExistOnSysFileMetadata(['creator']); + + $file = $this->createImageFile([]); + + self::assertFalse( + $this->subject->hasValidMetaData($file), + ); + } + + #[Test] + public function hasValidMetaDataReturnsTrueWhenNoColumnsAreRequired(): void + { + $this->configureRequiredColumnsConfiguration(''); + + $file = $this->createImageFile([]); + + self::assertTrue( + $this->subject->hasValidMetaData($file), + ); + } + + #[Test] + public function hasValidMetaDataReturnsTrueWhenAllRequiredColumnsAreFilled(): void + { + $this->configureRequiredColumnsConfiguration('creator'); + $this->makeColumnsExistOnSysFileMetadata(['creator']); + + $file = $this->createImageFile(['creator' => 'John Doe']); + + self::assertTrue( + $this->subject->hasValidMetaData($file), + ); + } +} diff --git a/Tests/Unit/Service/PlaceholderImageServiceTest.php b/Tests/Unit/Service/PlaceholderImageServiceTest.php new file mode 100644 index 0000000..4ebcada --- /dev/null +++ b/Tests/Unit/Service/PlaceholderImageServiceTest.php @@ -0,0 +1,545 @@ +extensionConfigurationMock = $this->createMock(ExtensionConfiguration::class); + $this->requestFactoryMock = $this->createMock(RequestFactory::class); + $this->resourceFactoryMock = $this->createMock(ResourceFactory::class); + + $this->subject = new PlaceholderImageService( + $this->extensionConfigurationMock, + $this->requestFactoryMock, + $this->resourceFactoryMock, + ); + } + + protected function tearDown(): void + { + unset( + $this->extensionConfigurationMock, + $this->requestFactoryMock, + $this->resourceFactoryMock, + $this->subject, + ); + + parent::tearDown(); + } + + /** + * @param array $configuration + */ + protected function configureExtensionConfiguration(array $configuration): void + { + $this->extensionConfigurationMock + ->method('get') + ->willReturnCallback( + static fn(string $extension, string $path = '') => $configuration[$path] ?? '', + ); + } + + protected function invokeProtectedMethod(string $method, array $arguments = []): mixed + { + $reflectionMethod = new \ReflectionMethod($this->subject, $method); + $reflectionMethod->setAccessible(true); + + return $reflectionMethod->invokeArgs($this->subject, $arguments); + } + + // -- getExtensionForContentType() ----------------------------------------------------- + + /** + * @return array> + */ + public static function contentTypeDataProvider(): array + { + return [ + 'png' => ['image/png', 'png'], + 'jpeg' => ['image/jpeg', 'jpg'], + 'jpg alias' => ['image/jpg', 'jpg'], + 'gif' => ['image/gif', 'gif'], + 'webp' => ['image/webp', 'webp'], + 'svg is not rasterizable' => ['image/svg+xml', null], + 'unknown type' => ['text/html', null], + 'empty string' => ['', null], + 'charset suffix is ignored' => ['image/png; charset=binary', 'png'], + 'case insensitive' => ['IMAGE/PNG', 'png'], + 'surrounding whitespace' => [' image/gif ', 'gif'], + ]; + } + + #[Test] + #[DataProvider('contentTypeDataProvider')] + public function getExtensionForContentTypeMapsKnownRasterTypes(string $contentType, ?string $expected): void + { + self::assertSame( + $expected, + $this->invokeProtectedMethod('getExtensionForContentType', [$contentType]), + ); + } + + // -- buildApiUrl() --------------------------------------------------------------------- + + #[Test] + public function buildApiUrlReturnsNullWhenTemplateIsEmpty(): void + { + $this->configureExtensionConfiguration([self::API_URL_KEY => '']); + + self::assertNull( + $this->invokeProtectedMethod('buildApiUrl', [300, 300]), + ); + } + + #[Test] + public function buildApiUrlReplacesWidthAndHeightPlaceholders(): void + { + $this->configureExtensionConfiguration([ + self::API_URL_KEY => 'https://placehold.co/{width}x{height}/eeeeee/999999.png?text=Metadata+missing', + ]); + + self::assertSame( + 'https://placehold.co/300x150/eeeeee/999999.png?text=Metadata+missing', + $this->invokeProtectedMethod('buildApiUrl', [300, 150]), + ); + } + + #[Test] + public function buildApiUrlTrimsTemplate(): void + { + $this->configureExtensionConfiguration([ + self::API_URL_KEY => ' https://example.org/{width}x{height}.png ', + ]); + + self::assertSame( + 'https://example.org/300x300.png', + $this->invokeProtectedMethod('buildApiUrl', [300, 300]), + ); + } + + // -- getCacheFolder() edge cases (no real storage needed) ------------------------------- + + #[Test] + public function getCacheFolderReturnsNullWhenIdentifierIsEmpty(): void + { + $this->configureExtensionConfiguration([self::STORAGE_FOLDER_KEY => '']); + + $this->resourceFactoryMock->expects(self::never())->method('getStorageObject'); + + self::assertNull( + $this->invokeProtectedMethod('getCacheFolder'), + ); + } + + #[Test] + public function getCacheFolderReturnsNullWhenIdentifierHasNoColon(): void + { + $this->configureExtensionConfiguration([self::STORAGE_FOLDER_KEY => 'placeholder_images']); + + $this->resourceFactoryMock->expects(self::never())->method('getStorageObject'); + + self::assertNull( + $this->invokeProtectedMethod('getCacheFolder'), + ); + } + + #[Test] + public function getCacheFolderReturnsNullWhenStorageUidPartIsEmpty(): void + { + $this->configureExtensionConfiguration([self::STORAGE_FOLDER_KEY => ':/placeholder_images/']); + + $this->resourceFactoryMock->expects(self::never())->method('getStorageObject'); + + self::assertNull( + $this->invokeProtectedMethod('getCacheFolder'), + ); + } + + #[Test] + public function getCacheFolderReturnsNullWhenFolderPathPartIsEmpty(): void + { + $this->configureExtensionConfiguration([self::STORAGE_FOLDER_KEY => '1:']); + + $this->resourceFactoryMock->expects(self::never())->method('getStorageObject'); + + self::assertNull( + $this->invokeProtectedMethod('getCacheFolder'), + ); + } + + #[Test] + public function getCacheFolderReturnsNullWhenStorageDoesNotExist(): void + { + $this->configureExtensionConfiguration([self::STORAGE_FOLDER_KEY => '1:/placeholder_images/']); + + $this->resourceFactoryMock + ->method('getStorageObject') + ->willThrowException(new \InvalidArgumentException('Storage does not exist', 1000)); + + self::assertNull( + $this->invokeProtectedMethod('getCacheFolder'), + ); + } + + #[Test] + public function getCacheFolderReturnsExistingFolder(): void + { + $this->configureExtensionConfiguration([self::STORAGE_FOLDER_KEY => '1:/placeholder_images/']); + + $folderMock = $this->createMock(Folder::class); + $storageMock = $this->createMock(ResourceStorage::class); + $storageMock->method('hasFolder')->with('/placeholder_images/')->willReturn(true); + $storageMock->expects(self::never())->method('createFolder'); + $storageMock->method('getFolder')->with('/placeholder_images/')->willReturn($folderMock); + + $this->resourceFactoryMock->method('getStorageObject')->with(1)->willReturn($storageMock); + + self::assertSame( + $folderMock, + $this->invokeProtectedMethod('getCacheFolder'), + ); + } + + #[Test] + public function getCacheFolderCreatesFolderWhenItDoesNotExistYet(): void + { + $this->configureExtensionConfiguration([self::STORAGE_FOLDER_KEY => '1:/placeholder_images/']); + + $folderMock = $this->createMock(Folder::class); + $storageMock = $this->createMock(ResourceStorage::class); + $storageMock->method('hasFolder')->willReturn(false); + $storageMock->expects(self::once()) + ->method('createFolder') + ->with('/placeholder_images/') + ->willReturn($folderMock); + + $this->resourceFactoryMock->method('getStorageObject')->willReturn($storageMock); + + self::assertSame( + $folderMock, + $this->invokeProtectedMethod('getCacheFolder'), + ); + } + + #[Test] + public function getCacheFolderReturnsNullWhenCreateFolderFails(): void + { + $this->configureExtensionConfiguration([self::STORAGE_FOLDER_KEY => '1:/placeholder_images/']); + + $storageMock = $this->createMock(ResourceStorage::class); + $storageMock->method('hasFolder')->willReturn(false); + $storageMock->method('createFolder')->willThrowException(new \RuntimeException('cannot create')); + + $this->resourceFactoryMock->method('getStorageObject')->willReturn($storageMock); + + self::assertNull( + $this->invokeProtectedMethod('getCacheFolder'), + ); + } + + // -- getPlaceholderFile() end-to-end with mocked collaborators -------------------------- + + protected function configureValidCacheFolder(): Folder&MockObject + { + $this->configureExtensionConfiguration([ + self::STORAGE_FOLDER_KEY => '1:/placeholder_images/', + self::API_URL_KEY => 'https://placehold.co/{width}x{height}.png', + ]); + + $folderMock = $this->createMock(Folder::class); + $storageMock = $this->createMock(ResourceStorage::class); + $storageMock->method('hasFolder')->willReturn(true); + $storageMock->method('getFolder')->willReturn($folderMock); + $this->resourceFactoryMock->method('getStorageObject')->willReturn($storageMock); + + return $folderMock; + } + + #[Test] + public function getPlaceholderFileReturnsNullWhenStorageFolderIsNotConfigured(): void + { + $this->configureExtensionConfiguration([self::STORAGE_FOLDER_KEY => '']); + + self::assertNull( + $this->subject->getPlaceholderFile(300, 300), + ); + } + + #[Test] + public function getPlaceholderFileReturnsAlreadyCachedFileWithoutCallingTheApi(): void + { + $folderMock = $this->configureValidCacheFolder(); + $cachedFileMock = $this->createMock(File::class); + + $folderMock->method('hasFile')->with('placeholder_300x300.png')->willReturn(true); + $folderMock->method('getFile')->with('placeholder_300x300.png')->willReturn($cachedFileMock); + + $this->requestFactoryMock->expects(self::never())->method('request'); + + self::assertSame( + $cachedFileMock, + $this->subject->getPlaceholderFile(300, 300), + ); + } + + #[Test] + public function getPlaceholderFileFindsCachedFileWithNonDefaultExtension(): void + { + // Only a ".gif" was cached for this dimension (e.g. from a differently configured + // API in the past) - the cache lookup must still find it instead of re-fetching. + $folderMock = $this->configureValidCacheFolder(); + $cachedFileMock = $this->createMock(File::class); + + $folderMock->method('hasFile')->willReturnCallback( + static fn(string $fileName) => $fileName === 'placeholder_300x300.gif', + ); + $folderMock->method('getFile')->with('placeholder_300x300.gif')->willReturn($cachedFileMock); + + $this->requestFactoryMock->expects(self::never())->method('request'); + + self::assertSame( + $cachedFileMock, + $this->subject->getPlaceholderFile(300, 300), + ); + } + + #[Test] + public function getPlaceholderFileFetchesAndStoresNewPngFile(): void + { + $folderMock = $this->configureValidCacheFolder(); + $folderMock->method('hasFile')->willReturn(false); + + $streamMock = $this->createMock(StreamInterface::class); + $streamMock->method('getContents')->willReturn('binary-image-data'); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->method('getStatusCode')->willReturn(200); + $responseMock->method('getHeaderLine')->with('Content-Type')->willReturn('image/png'); + $responseMock->method('getBody')->willReturn($streamMock); + + $this->requestFactoryMock + ->expects(self::once()) + ->method('request') + ->with('https://placehold.co/300x300.png') + ->willReturn($responseMock); + + $storedFileMock = $this->createMock(File::class); + $folderMock->expects(self::once()) + ->method('addFile') + ->with(self::isType('string'), 'placeholder_300x300.png') + ->willReturn($storedFileMock); + + self::assertSame( + $storedFileMock, + $this->subject->getPlaceholderFile(300, 300), + ); + } + + #[Test] + public function getPlaceholderFileFallsBackWhenApiReturnsNonRasterizableContentType(): void + { + $folderMock = $this->configureValidCacheFolder(); + + $fallbackFileMock = $this->createMock(File::class); + $folderMock->method('hasFile')->willReturnCallback( + static fn(string $fileName) => $fileName === 'placeholder_fallback.png', + ); + $folderMock->method('getFile')->with('placeholder_fallback.png')->willReturn($fallbackFileMock); + + $streamMock = $this->createMock(StreamInterface::class); + $streamMock->method('getContents')->willReturn(''); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->method('getStatusCode')->willReturn(200); + $responseMock->method('getHeaderLine')->willReturn('image/svg+xml'); + $responseMock->method('getBody')->willReturn($streamMock); + + $this->requestFactoryMock->method('request')->willReturn($responseMock); + + $folderMock->expects(self::never())->method('addFile'); + + self::assertSame( + $fallbackFileMock, + $this->subject->getPlaceholderFile(300, 300), + ); + } + + #[Test] + public function getPlaceholderFileFallsBackWhenApiReturnsErrorStatus(): void + { + $folderMock = $this->configureValidCacheFolder(); + + $fallbackFileMock = $this->createMock(File::class); + $folderMock->method('hasFile')->willReturnCallback( + static fn(string $fileName) => $fileName === 'placeholder_fallback.png', + ); + $folderMock->method('getFile')->with('placeholder_fallback.png')->willReturn($fallbackFileMock); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->method('getStatusCode')->willReturn(500); + + $this->requestFactoryMock->method('request')->willReturn($responseMock); + + self::assertSame( + $fallbackFileMock, + $this->subject->getPlaceholderFile(300, 300), + ); + } + + #[Test] + public function getPlaceholderFileFallsBackWhenRequestThrows(): void + { + $folderMock = $this->configureValidCacheFolder(); + + $fallbackFileMock = $this->createMock(File::class); + $folderMock->method('hasFile')->willReturnCallback( + static fn(string $fileName) => $fileName === 'placeholder_fallback.png', + ); + $folderMock->method('getFile')->with('placeholder_fallback.png')->willReturn($fallbackFileMock); + + $this->requestFactoryMock + ->method('request') + ->willThrowException(new \RuntimeException('Connection refused')); + + self::assertSame( + $fallbackFileMock, + $this->subject->getPlaceholderFile(300, 300), + ); + } + + #[Test] + public function getPlaceholderFileFallsBackWhenNoApiUrlIsConfigured(): void + { + $folderMock = $this->createMock(Folder::class); + $storageMock = $this->createMock(ResourceStorage::class); + $storageMock->method('hasFolder')->willReturn(true); + $storageMock->method('getFolder')->willReturn($folderMock); + $this->resourceFactoryMock->method('getStorageObject')->willReturn($storageMock); + + $this->configureExtensionConfiguration([ + self::STORAGE_FOLDER_KEY => '1:/placeholder_images/', + self::API_URL_KEY => '', + ]); + + $fallbackFileMock = $this->createMock(File::class); + $folderMock->method('hasFile')->willReturnCallback( + static fn(string $fileName) => $fileName === 'placeholder_fallback.png', + ); + $folderMock->method('getFile')->with('placeholder_fallback.png')->willReturn($fallbackFileMock); + + $this->requestFactoryMock->expects(self::never())->method('request'); + + self::assertSame( + $fallbackFileMock, + $this->subject->getPlaceholderFile(300, 300), + ); + } + + #[Test] + public function getPlaceholderFileFallsBackWhenStoringTheDownloadedFileFails(): void + { + $folderMock = $this->configureValidCacheFolder(); + + $fallbackFileMock = $this->createMock(File::class); + $folderMock->method('hasFile')->willReturnCallback( + static fn(string $fileName) => $fileName === 'placeholder_fallback.png', + ); + $folderMock->method('getFile')->with('placeholder_fallback.png')->willReturn($fallbackFileMock); + + $streamMock = $this->createMock(StreamInterface::class); + $streamMock->method('getContents')->willReturn('binary-image-data'); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->method('getStatusCode')->willReturn(200); + $responseMock->method('getHeaderLine')->willReturn('image/png'); + $responseMock->method('getBody')->willReturn($streamMock); + + $this->requestFactoryMock->method('request')->willReturn($responseMock); + + $folderMock->method('addFile')->willThrowException(new \RuntimeException('disk full')); + + self::assertSame( + $fallbackFileMock, + $this->subject->getPlaceholderFile(300, 300), + ); + } + + #[Test] + public function getPlaceholderFileReturnsNullWhenNothingIsCachedApiIsNotConfiguredAndBundledAssetCannotBeResolved(): void + { + // GeneralUtility::getFileAbsFileName('EXT:jwtools2/...') can only resolve to a real path + // when the extension is registered as an active package - that is only guaranteed in a + // Functional test (see Tests/Functional/Service/PlaceholderImageServiceTest.php, which + // covers the successful "bundled asset" path end-to-end). In this Unit test context the + // package is not registered, so resolution fails and getFallbackFile() must degrade to + // null rather than throwing. + $folderMock = $this->createMock(Folder::class); + $storageMock = $this->createMock(ResourceStorage::class); + $storageMock->method('hasFolder')->willReturn(true); + $storageMock->method('getFolder')->willReturn($folderMock); + $this->resourceFactoryMock->method('getStorageObject')->willReturn($storageMock); + + $this->configureExtensionConfiguration([ + self::STORAGE_FOLDER_KEY => '1:/placeholder_images/', + self::API_URL_KEY => '', + ]); + + $folderMock->method('hasFile')->willReturn(false); + $folderMock->expects(self::never())->method('addFile'); + + self::assertNull($this->subject->getPlaceholderFile(300, 300)); + } +}