Files

307 lines
14 KiB
PHP

<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\View\BackendLayout;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendLayoutView;
use TYPO3\CMS\Backend\View\Event\IsContentUsedOnPageLayoutEvent;
use TYPO3\CMS\Backend\View\Event\ModifyDatabaseQueryForContentEvent;
use TYPO3\CMS\Backend\View\PageLayoutContext;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Versioning\VersionState;
/**
* Class responsible for fetching the content data related to a BackendLayout
*
* - Reads content records
* - Performs workspace overlay on records
* - Capable of returning all records in active language as flat array
* - Capable of returning records for a given column in a given (optional) language
* - Capable of returning translation data (brief info about translation consistency)
*
* @internal
*/
#[Autoconfigure(public: true)]
readonly class ContentFetcher
{
public function __construct(
private EventDispatcherInterface $eventDispatcher,
#[Autowire(service: 'cache.runtime')]
private FrontendInterface $runtimeCache,
private ConnectionPool $connectionPool,
private TcaSchemaFactory $tcaSchemaFactory,
private FlashMessageService $flashMessageService,
private BackendLayoutView $backendLayoutView,
) {}
/**
* Gets content records per column.
* This is required for correct workspace overlays.
*
* @return array Associative array for each column (colPos) or for all columns if $columnNumber is null
*/
public function getContentRecordsPerColumn(PageLayoutContext $pageLayoutContext, ?int $columnNumber = null, ?int $languageId = null): array
{
$languageId = $languageId ?? $pageLayoutContext->getSiteLanguage()->getLanguageId();
$cachedFetchedContentRecords = $this->runtimeCache->get('ContentFetcher_fetchedContentRecords') ?: [];
if (empty($cachedFetchedContentRecords)) {
$fetchedContentRecords = [];
$isLanguageComparisonMode = $pageLayoutContext->getDrawingConfiguration()->isLanguageComparisonMode();
$queryBuilder = $this->getQueryBuilder($pageLayoutContext);
$result = $queryBuilder->executeQuery();
$records = $this->getResult($result);
foreach ($records as $record) {
$recordLanguage = $record['language_tag'] ?? '';
$recordColumnNumber = (int)$record['colPos'];
if ($recordLanguage === -1) {
// Record is set to "all languages", place it according to view mode.
if ($isLanguageComparisonMode) {
// Force the record to only be shown in default language in "Languages" view mode.
$recordLanguage = 0;
} else {
// Force the record to be shown in the currently active language in "Columns" view mode.
$recordLanguage = $languageId;
}
}
$fetchedContentRecords[$recordLanguage][$recordColumnNumber][] = $record;
}
$this->runtimeCache->set('ContentFetcher_fetchedContentRecords', $fetchedContentRecords);
} else {
$fetchedContentRecords = $cachedFetchedContentRecords;
}
$contentByLanguage = $fetchedContentRecords[$languageId] ?? [];
if ($columnNumber === null) {
return $contentByLanguage;
}
return $contentByLanguage[$columnNumber] ?? [];
}
public function getFlatContentRecords(PageLayoutContext $pageLayoutContext, int $languageId): iterable
{
$contentRecords = $this->getContentRecordsPerColumn($pageLayoutContext, null, $languageId);
return empty($contentRecords) ? [] : array_merge(...$contentRecords);
}
/**
* Allows to decide via an Event whether a custom type has children which were rendered or should not be rendered.
*/
public function getUnusedRecords(PageLayoutContext $pageLayoutContext): iterable
{
$unrendered = [];
$recordIdentityMap = $pageLayoutContext->getRecordIdentityMap();
$languageId = $pageLayoutContext->getDrawingConfiguration()->getPrimaryLanguageId();
// @todo consider to invoke the identity-map much earlier (to avoid fetching database records again)
foreach ($this->getContentRecordsPerColumn($pageLayoutContext, null, $languageId) as $contentRecordsInColumn) {
foreach ($contentRecordsInColumn as $contentRecord) {
$used = $recordIdentityMap->hasIdentifier('tt_content', (int)$contentRecord['uid']);
// A hook mentioned that this record is used somewhere, so this is in fact "rendered" already
$event = new IsContentUsedOnPageLayoutEvent($contentRecord, $used, $pageLayoutContext);
$event = $this->eventDispatcher->dispatch($event);
if (!$event->isRecordUsed()) {
$unrendered[] = $contentRecord;
}
}
}
return $unrendered;
}
public function getTranslationData(PageLayoutContext $pageLayoutContext, iterable $contentElements, int $language): array
{
if ($language === 0) {
return [];
}
$languageTranslationInfo = $this->runtimeCache->get('ContentFetcher_TranslationInfo_' . $language) ?: [];
if (empty($languageTranslationInfo)) {
$contentRecordsInDefaultLanguage = $this->getContentRecordsPerColumn($pageLayoutContext, null, 0);
if (!empty($contentRecordsInDefaultLanguage)) {
$contentRecordsInDefaultLanguage = array_merge(...$contentRecordsInDefaultLanguage);
}
$untranslatedRecordUids = array_flip(
array_column(
// Eliminate records with "-1" as sys_language_uid since they can not be translated
array_filter($contentRecordsInDefaultLanguage, static function (array $record): bool {
return true;
}),
'uid'
)
);
foreach ($contentElements as $contentElement) {
if (($contentElement['language_tag'] ?? '') === '') {
continue;
}
if ((int)$contentElement['l18n_parent'] === 0) {
$languageTranslationInfo['hasStandAloneContent'] = true;
$languageTranslationInfo['mode'] = 'free';
}
if ((int)$contentElement['l18n_parent'] > 0) {
$languageTranslationInfo['hasTranslations'] = true;
$languageTranslationInfo['mode'] = 'connected';
}
if ((int)$contentElement['l10n_source'] > 0) {
unset($untranslatedRecordUids[(int)$contentElement['l10n_source']]);
}
}
if (!isset($languageTranslationInfo['hasTranslations'])) {
$languageTranslationInfo['hasTranslations'] = false;
}
foreach ($untranslatedRecordUids as $uid => $index) {
$contentElementInDefaultLanguage = $contentRecordsInDefaultLanguage[$index];
if (!$this->backendLayoutView->isCTypeAllowedInColPosByPage($contentElementInDefaultLanguage['CType'], $contentElementInDefaultLanguage['colPos'], $contentElementInDefaultLanguage['pid'])) {
unset($untranslatedRecordUids[$uid]);
}
}
$untranslatedRecordUidsWithoutWorkspaceDeletedRecords = $this->removeWorkspaceDeletedPlaceholdersUidsFromUntranslatedRecordUids($pageLayoutContext, array_keys($untranslatedRecordUids), $language);
$languageTranslationInfo['untranslatedRecordUids'] = $untranslatedRecordUidsWithoutWorkspaceDeletedRecords;
if (array_keys($untranslatedRecordUids) !== $untranslatedRecordUidsWithoutWorkspaceDeletedRecords) {
$languageTranslationInfo['hasElementsWithWorkspaceDeletePlaceholders'] = true;
}
// Check for inconsistent translations, force "mixed" mode and dispatch a FlashMessage to user if such a case is encountered.
if (isset($languageTranslationInfo['hasStandAloneContent'])
&& $languageTranslationInfo['hasTranslations']
) {
$languageTranslationInfo['mode'] = 'mixed';
// We do not want to show the staleTranslationWarning if allowInconsistentLanguageHandling is enabled
if (!$pageLayoutContext->getDrawingConfiguration()->getAllowInconsistentLanguageHandling()) {
$siteLanguage = $pageLayoutContext->getSiteLanguage($language);
$languageService = $this->getLanguageService();
$message = FlashMessage::createFromArray([
'message' => $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:staleTranslationWarning'),
'title' => sprintf($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:staleTranslationWarningTitle'), $siteLanguage->getTitle()),
'severity' => ContextualFeedbackSeverity::WARNING->value,
]);
$queue = $this->flashMessageService->getMessageQueueByIdentifier();
$queue->addMessage($message);
}
}
$this->runtimeCache->set('ContentFetcher_TranslationInfo_' . $language, $languageTranslationInfo);
}
return $languageTranslationInfo;
}
protected function removeWorkspaceDeletedPlaceholdersUidsFromUntranslatedRecordUids(PageLayoutContext $pageLayoutContext, array $untranslatedRecordUids, int $language): array
{
if ($this->getBackendUser()->workspace <= 0) {
// Early return if we're not in a workspace to suppress some queries.
return $untranslatedRecordUids;
}
$queryBuilder = $this->getQueryBuilder($pageLayoutContext);
$queryBuilder->andWhere(
$queryBuilder->expr()->and(
$queryBuilder->expr()->in(
'l18n_parent',
$queryBuilder->createNamedParameter($untranslatedRecordUids, Connection::PARAM_INT_ARRAY)
),
$queryBuilder->expr()->eq(
'sys_language_uid',
$queryBuilder->createNamedParameter($language, Connection::PARAM_INT)
)
)
);
$result = $queryBuilder->executeQuery();
$uidsToRemoveFromUntranslatedRecordUids = [];
while ($row = $result->fetchAssociative()) {
BackendUtility::workspaceOL('tt_content', $row, -99, true);
if ($row && VersionState::tryFrom($row['t3ver_state'] ?? 0) === VersionState::DELETE_PLACEHOLDER) {
$uidsToRemoveFromUntranslatedRecordUids[] = $row['l18n_parent'];
}
}
return array_diff($untranslatedRecordUids, $uidsToRemoveFromUntranslatedRecordUids);
}
protected function getQueryBuilder(PageLayoutContext $pageLayoutContext): QueryBuilder
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace));
$queryBuilder
->select('*')
->from('tt_content');
$queryBuilder->andWhere(
$queryBuilder->expr()->eq(
'tt_content.pid',
$queryBuilder->createNamedParameter($pageLayoutContext->getPageId(), Connection::PARAM_INT)
)
);
$schema = $this->tcaSchemaFactory->get('tt_content');
$sortBy = $schema->hasCapability(TcaSchemaCapability::SortByField) ? (string)$schema->getCapability(TcaSchemaCapability::SortByField) : '';
if ($sortBy === '' && $schema->hasCapability(TcaSchemaCapability::DefaultSorting)) {
$sortBy = (string)$schema->getCapability(TcaSchemaCapability::DefaultSorting)->getValue();
}
foreach (QueryHelper::parseOrderBy($sortBy) as $orderBy) {
$queryBuilder->addOrderBy($orderBy[0], $orderBy[1]);
}
$event = new ModifyDatabaseQueryForContentEvent($queryBuilder, 'tt_content', $pageLayoutContext->getPageId());
$event = $this->eventDispatcher->dispatch($event);
return $event->getQueryBuilder();
}
protected function getResult($result): array
{
$output = [];
while ($row = $result->fetchAssociative()) {
BackendUtility::workspaceOL('tt_content', $row, -99, true);
if ($row && VersionState::tryFrom($row['t3ver_state'] ?? 0) !== VersionState::DELETE_PLACEHOLDER) {
$output[] = $row;
}
}
return $output;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
public function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}