diff --git a/.github/workflows/ter-release.yml b/.github/workflows/ter-release.yml new file mode 100644 index 00000000..76c2e895 --- /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 }}" diff --git a/Build/phpunit/UnitTestsBootstrap.php b/Build/phpunit/UnitTestsBootstrap.php index ec17508c..fac559ad 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); diff --git a/Classes/Command/CacheQueryCommand.php b/Classes/Command/CacheQueryCommand.php index c1133cdb..edafb64c 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; } diff --git a/Classes/Command/ConvertPlainPasswordToHashCommand.php b/Classes/Command/ConvertPlainPasswordToHashCommand.php index d053f815..500ca6b5 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; diff --git a/Classes/Command/StatusReportCommand.php b/Classes/Command/StatusReportCommand.php index 7dbf1cff..6b50c87f 100644 --- a/Classes/Command/StatusReportCommand.php +++ b/Classes/Command/StatusReportCommand.php @@ -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' diff --git a/Classes/ContextMenu/ItemProviders/UpdateFileMetaDataProvider.php b/Classes/ContextMenu/ItemProviders/UpdateFileMetaDataProvider.php index d791e81a..c112d1e3 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; diff --git a/Classes/Controller/Ajax/AjaxSolrController.php b/Classes/Controller/Ajax/AjaxSolrController.php index b41997d0..e861b359 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) { } } diff --git a/Classes/Controller/Ajax/SysFileController.php b/Classes/Controller/Ajax/SysFileController.php index 209d1762..a194d0d9 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); diff --git a/Classes/Controller/FileMetaDataController.php b/Classes/Controller/FileMetaDataController.php new file mode 100644 index 00000000..95479767 --- /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; + } + } +} diff --git a/Classes/Controller/SolrController.php b/Classes/Controller/SolrController.php index 4f8666db..92c3114b 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']) diff --git a/Classes/Database/Query/QueryGenerator.php b/Classes/Database/Query/QueryGenerator.php index 7cc91e17..0775b429 100644 --- a/Classes/Database/Query/QueryGenerator.php +++ b/Classes/Database/Query/QueryGenerator.php @@ -22,6 +22,8 @@ */ class QueryGenerator { + public function __construct(private readonly ConnectionPool $connectionPool) {} + /** * @throws Exception */ @@ -37,7 +39,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') diff --git a/Classes/Domain/Repository/SchedulerRepository.php b/Classes/Domain/Repository/SchedulerRepository.php index db1bf8c7..abc2ef6a 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); - } } diff --git a/Classes/Domain/Repository/SolrRepository.php b/Classes/Domain/Repository/SolrRepository.php index e5c3d6e4..810e017a 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; } } diff --git a/Classes/EventListener/AfterContentObjectRendererInitializedEventListener.php b/Classes/EventListener/AfterContentObjectRendererInitializedEventListener.php index 9771ae69..812af68d 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 []; } } diff --git a/Classes/EventListener/DisableRecordsWithInvalidFileMetaDataEventListener.php b/Classes/EventListener/DisableRecordsWithInvalidFileMetaDataEventListener.php new file mode 100644 index 00000000..2d1f9b3a --- /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; + } +} diff --git a/Classes/EventListener/IndexServiceEventListener.php b/Classes/EventListener/IndexServiceEventListener.php index 93c3e14b..a855c695 100644 --- a/Classes/EventListener/IndexServiceEventListener.php +++ b/Classes/EventListener/IndexServiceEventListener.php @@ -13,16 +13,16 @@ 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)); } } diff --git a/Classes/EventListener/IsFileSelectableEventListener.php b/Classes/EventListener/IsFileSelectableEventListener.php index deebe418..b070b38e 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); - } } diff --git a/Classes/EventListener/ReduceCategoryTreeToPageTree.php b/Classes/EventListener/ReduceCategoryTreeToPageTree.php index 660a812d..44883e74 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); - } } diff --git a/Classes/EventListener/ReplaceInvalidMetaDataImageEventListener.php b/Classes/EventListener/ReplaceInvalidMetaDataImageEventListener.php new file mode 100644 index 00000000..277390c5 --- /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; + } + } +} diff --git a/Classes/Hooks/CachingFrameworkLoggerHook.php b/Classes/Hooks/CachingFrameworkLoggerHook.php index cef9801d..d9fdd42f 100644 --- a/Classes/Hooks/CachingFrameworkLoggerHook.php +++ b/Classes/Hooks/CachingFrameworkLoggerHook.php @@ -28,6 +28,8 @@ 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 +59,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 +112,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 +167,6 @@ protected function getCacheExpressionRecords(): array protected function getConnectionPool(): ConnectionPool { - return GeneralUtility::makeInstance(ConnectionPool::class); + return $this->connectionPool; } } diff --git a/Classes/Hooks/IndexService.php b/Classes/Hooks/IndexService.php index e82a25c2..19e2f939 100644 --- a/Classes/Hooks/IndexService.php +++ b/Classes/Hooks/IndexService.php @@ -14,13 +14,11 @@ 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 +27,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)); } } diff --git a/Classes/Hooks/ModifyElementInformationHook.php b/Classes/Hooks/ModifyElementInformationHook.php index d3379b6b..21985f8d 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( diff --git a/Classes/Hooks/MoveTranslatedContentElementsHook.php b/Classes/Hooks/MoveTranslatedContentElementsHook.php index d4709b0c..bb7c6cfb 100644 --- a/Classes/Hooks/MoveTranslatedContentElementsHook.php +++ b/Classes/Hooks/MoveTranslatedContentElementsHook.php @@ -24,13 +24,15 @@ */ 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 +98,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 +121,4 @@ protected function getOverlayRecords($uid, DataHandler $dataHandler): array return $contentRecords; } - - protected function getConnectionPool(): ConnectionPool - { - return GeneralUtility::makeInstance(ConnectionPool::class); - } } diff --git a/Classes/Hooks/ValidateFileMetaDataOnSaveHook.php b/Classes/Hooks/ValidateFileMetaDataOnSaveHook.php new file mode 100644 index 00000000..a96f9e8c --- /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; + } +} diff --git a/Classes/Service/FileMetaDataValidationService.php b/Classes/Service/FileMetaDataValidationService.php new file mode 100644 index 00000000..6be2f9fb --- /dev/null +++ b/Classes/Service/FileMetaDataValidationService.php @@ -0,0 +1,115 @@ +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 = []; + // 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. + 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; + } +} diff --git a/Classes/Service/FileReferenceResolverService.php b/Classes/Service/FileReferenceResolverService.php new file mode 100644 index 00000000..4db5c4b0 --- /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'], + ]; + } +} diff --git a/Classes/Service/PlaceholderImageService.php b/Classes/Service/PlaceholderImageService.php new file mode 100644 index 00000000..3efe5974 --- /dev/null +++ b/Classes/Service/PlaceholderImageService.php @@ -0,0 +1,243 @@ +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 ''; + } + } +} diff --git a/Classes/Service/SolrService.php b/Classes/Service/SolrService.php index b837901f..0d5351e8 100644 --- a/Classes/Service/SolrService.php +++ b/Classes/Service/SolrService.php @@ -23,13 +23,15 @@ */ 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 +96,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 +120,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 +145,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); - } } diff --git a/Classes/Task/ExecuteQueryTask.php b/Classes/Task/ExecuteQueryTask.php index 3e83b4b5..d40123ff 100644 --- a/Classes/Task/ExecuteQueryTask.php +++ b/Classes/Task/ExecuteQueryTask.php @@ -25,6 +25,13 @@ 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 +87,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; } } diff --git a/Classes/Task/IndexQueueWorkerTask.php b/Classes/Task/IndexQueueWorkerTask.php index 37d9c5b8..bd906c2a 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); - } } diff --git a/Classes/ViewHelpers/Solr/NextRunViewHelper.php b/Classes/ViewHelpers/Solr/NextRunViewHelper.php index be514993..2e113414 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; } diff --git a/Classes/ViewHelpers/SplitFileRefViewHelper.php b/Classes/ViewHelpers/SplitFileRefViewHelper.php index 4f5acacf..37778af5 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, ); } diff --git a/Configuration/Backend/AjaxRoutes.php b/Configuration/Backend/AjaxRoutes.php index 9b8251c5..b691aeac 100644 --- a/Configuration/Backend/AjaxRoutes.php +++ b/Configuration/Backend/AjaxRoutes.php @@ -1,5 +1,7 @@ [ 'list', 'show', 'showIndexQueue', 'indexOneRecord', 'showClearIndexForm', 'clearIndex', 'showClearFullIndexForm', ], + FileMetaDataController::class => [ + 'list', + ], ], ], ]; diff --git a/Configuration/Icons.php b/Configuration/Icons.php index 64a696e6..7f9bf157 100644 --- a/Configuration/Icons.php +++ b/Configuration/Icons.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', ], ], diff --git a/Configuration/user.tsconfig b/Configuration/user.tsconfig new file mode 100644 index 00000000..fbc75cdd --- /dev/null +++ b/Configuration/user.tsconfig @@ -0,0 +1 @@ +options.pageTree.showPageIdWithTitle = 1 diff --git a/Resources/Private/Language/ExtConf.xlf b/Resources/Private/Language/ExtConf.xlf index c2f0b95e..59cab036 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/Resources/Private/Language/de.ExtConf.xlf b/Resources/Private/Language/de.ExtConf.xlf new file mode 100644 index 00000000..a96067a2 --- /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 00000000..e69e4411 --- /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 00000000..02deffc4 --- /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 00000000..de9cacfd --- /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 + + + + diff --git a/Resources/Private/Language/locallang.xlf b/Resources/Private/Language/locallang.xlf index 860f2ffb..d660dcde 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 83cb5439..e741ca8a 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 7f7b9222..de9cacfd 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 + + + diff --git a/Resources/Private/Templates/FileMetaData/List.html b/Resources/Private/Templates/FileMetaData/List.html new file mode 100644 index 00000000..14c9a234 --- /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')}
+ + +
+ diff --git a/Resources/Private/Templates/Tools/Overview.html b/Resources/Private/Templates/Tools/Overview.html index 7faf4ea2..c5539008 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')} + + diff --git a/Resources/Public/Images/MetaDataPlaceholder.png b/Resources/Public/Images/MetaDataPlaceholder.png new file mode 100644 index 00000000..91649edf Binary files /dev/null and b/Resources/Public/Images/MetaDataPlaceholder.png differ diff --git a/Tests/Unit/Controller/FileMetaDataControllerTest.php b/Tests/Unit/Controller/FileMetaDataControllerTest.php new file mode 100644 index 00000000..8c0fcbaf --- /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 00000000..b6720852 --- /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 00000000..67572a6a --- /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 00000000..4ebcada5 --- /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)); + } +} diff --git a/Tests/Unit/Task/ExecuteQueryTaskTest.php b/Tests/Unit/Task/ExecuteQueryTaskTest.php index c279ef6b..c28620fe 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(), ); } } diff --git a/ext_conf_template.txt b/ext_conf_template.txt index 772c5897..2e1f5beb 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 diff --git a/ext_emconf.php b/ext_emconf.php index c8a0a11d..6934cf07 100644 --- a/ext_emconf.php +++ b/ext_emconf.php @@ -1,5 +1,7 @@ '8.1.1', 'constraints' => [ 'depends' => [ - 'typo3' => '13.4.0-13.4.99', + 'typo3' => '13.4.24-13.4.99', ], 'conflicts' => [ ], diff --git a/ext_localconf.php b/ext_localconf.php index db67102f..ce452bd0 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;