Commit da9c6df5 authored by Oliver Bartsch's avatar Oliver Bartsch Committed by Benni Mack
Browse files

[TASK] Avoid usages of sys_language in site configuration

This patch removes the last remains of sys_language in the
site configurations' TCA.

Therefore, the TCA of `site` and `site_langauge` is changed
to always retrieve possible site languages via an itemsProcFunc,
instead of using a relation to sys_language.

As new site languages now have to be created in the module
directly, a new internal TCA type "siteLanguage" is introduced.
The new type behaves similar to type "inline", but contains some
necessary features, e.g. unique record selector box next to a
"create new" button, which are not available in type "inline".
Also some not needed functionality is omitted.

Instead of the sys_language records, all available site languages
from all existing site configurations are now presented in the
selector box. On selecting one of them, a new site language record
is created, with most of the fields pre filled.

Resolves: #94399
Releases: master
Change-Id: I60ac5b4259aa3c9d90a4aba9881bc1dc2341b464
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/69188

Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
parent fb67447d
<?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\Configuration\TCA;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This class provides items processor functions for the usage in TCA definition
* @internal
*/
class ItemsProcessorFunctions
{
/**
* Return languages found in already existing site configurations,
* sorted by their value. In case the same language is used with
* different titles, they will be added to the items label field.
* Additionally, a placeholder value is added to allow the creation
* of new site languages.
*/
public function populateAvailableLanguagesFromSites(array &$fieldDefinition): void
{
foreach (GeneralUtility::makeInstance(SiteFinder::class)->getAllSites() as $site) {
foreach ($site->getAllLanguages() as $languageId => $language) {
if (!isset($fieldDefinition['items'][$languageId])) {
$fieldDefinition['items'][$languageId] = [
$language->getTitle(),
$languageId,
$language->getFlagIdentifier(),
[]
];
} elseif ($fieldDefinition['items'][$languageId][0] !== $language->getTitle()) {
// Temporarily store different titles
$fieldDefinition['items'][$languageId][3][] = $language->getTitle();
}
}
}
if (!isset($fieldDefinition['items'][0])) {
// Since TcaSiteLanguage has a special behaviour, enforcing the
// default language ("0") to be always added to the site configuration,
// we have to add it to the available items, in case it is not already
// present. This only happens for the first ever created site configuration.
$fieldDefinition['items'][] = ['Default', 0, '', []];
}
ksort($fieldDefinition['items']);
// Build the final language label
foreach ($fieldDefinition['items'] as &$language) {
$language[0] .= ' [' . $language[1] . ']';
if ($language[3] !== []) {
$language[0] .= ' (' . implode(',', array_unique($language[3])) . ')';
// Unset the temporary title "storage"
unset($language[3]);
}
}
unset($language);
// Add PHP_INT_MAX as last - placeholder - value to allow creation of new records
// with the "Create new" button, which is usually not possible in "selector" mode.
// Note: The placeholder will never be displayed in the selector.
$fieldDefinition['items'] = array_values(
array_merge($fieldDefinition['items'], [['Placeholder', PHP_INT_MAX, '']])
);
}
/**
* Return language items for use in site_languages.fallbacks
*
* @param array $fieldDefinition
*/
public function populateFallbackLanguages(array &$fieldDefinition): void
{
foreach (GeneralUtility::makeInstance(SiteFinder::class)->getAllSites() as $site) {
foreach ($site->getAllLanguages() as $languageId => $language) {
if (isset($fieldDefinition['row']['languageId'][0])
&& (int)$fieldDefinition['row']['languageId'][0] === $languageId
) {
// Skip current language id
continue;
}
if (!isset($fieldDefinition['items'][$languageId])) {
$fieldDefinition['items'][$languageId] = [
$language->getTitle(),
$languageId,
$language->getFlagIdentifier(),
[]
];
} elseif ($fieldDefinition['items'][$languageId][0] !== $language->getTitle()) {
// Temporarily store different titles
$fieldDefinition['items'][$languageId][3][] = $language->getTitle();
}
}
}
ksort($fieldDefinition['items']);
// Build the final language label
foreach ($fieldDefinition['items'] as &$language) {
if ($language[3] !== []) {
$language[0] .= ' (' . implode(',', array_unique($language[3])) . ')';
// Unset the temporary title "storage"
unset($language[3]);
}
}
unset($language);
$fieldDefinition['items'] = array_values($fieldDefinition['items']);
}
}
......@@ -20,6 +20,7 @@ namespace TYPO3\CMS\Backend\Configuration\TCA;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\CommandUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
/**
* This class provides user functions for the usage in TCA definition
......@@ -35,11 +36,20 @@ class UserFunctions
public function getSiteLanguageTitle(array &$parameters): void
{
$record = $parameters['row'];
$languageId = (int)($record['languageId'][0] ?? 0);
if ($languageId === PHP_INT_MAX && StringUtility::beginsWith((string)($record['uid'] ?? ''), 'NEW')) {
// If we deal with a new record, created via "Create new" (indicated by the PHP_INT_MAX placeholder),
// we use a label as record title, until the real values, especially the language ID, are calculated.
$parameters['title'] = '[' . $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration_tca.xlf:site.languages.new') . ']';
return;
}
$parameters['title'] = sprintf(
'%s %s [%d] (%s) Base: %s',
$record['enabled'] ? '' : '[' . $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:disabled') . ']',
$record['title'],
(int)($record['languageId'][0] ?? 0),
$languageId,
$record['locale'],
$record['base']
);
......
......@@ -334,6 +334,62 @@ class SiteConfigurationController
}
break;
case 'siteLanguage':
if (!isset($siteTca['site_language'])) {
throw new \RuntimeException('Required foreign table site_language does not exist', 1624286811);
}
if (!isset($siteTca['site_language']['columns']['languageId'])
|| ($siteTca['site_language']['columns']['languageId']['config']['type'] ?? '') !== 'select'
) {
throw new \RuntimeException(
'Required foreign field languageId does not exist or is not of type select',
1624286812
);
}
$newSysSiteData[$fieldName] = [];
$lastLanguageId = $this->getLastLanguageId();
foreach (GeneralUtility::trimExplode(',', $fieldValue, true) as $childRowId) {
if (!isset($data['site_language'][$childRowId])) {
if (!empty($currentSiteConfiguration[$fieldName][$childRowId])) {
$newSysSiteData[$fieldName][] = $currentSiteConfiguration[$fieldName][$childRowId];
continue;
}
throw new \RuntimeException('No data found for table site_language with id ' . $childRowId, 1624286813);
}
$childRowData = [];
foreach ($data['site_language'][$childRowId] ?? [] as $childFieldName => $childFieldValue) {
if ($childFieldName === 'pid') {
// pid is added by default, but not relevant for yml storage
continue;
}
if ($childFieldName === 'languageId'
&& (int)$childFieldValue === PHP_INT_MAX
&& StringUtility::beginsWith($childRowId, 'NEW')
) {
// In case we deal with a new site language, whose "languageID" field is
// set to the PHP_INT_MAX placeholder, the next available language ID has
// to be used (auto-increment).
$childRowData[$childFieldName] = ++$lastLanguageId;
continue;
}
$type = $siteTca['site_language']['columns'][$childFieldName]['config']['type'];
switch ($type) {
case 'input':
case 'select':
case 'text':
$childRowData[$childFieldName] = $childFieldValue;
break;
case 'check':
$childRowData[$childFieldName] = (bool)$childFieldValue;
break;
default:
throw new \RuntimeException('TCA type ' . $type . ' not implemented in site handling', 1624286814);
}
}
$newSysSiteData[$fieldName][] = $childRowData;
}
break;
case 'select':
if (MathUtility::canBeInterpretedAsInteger($fieldValue)) {
$fieldValue = (int)$fieldValue;
......@@ -736,6 +792,24 @@ class SiteConfigurationController
}, ARRAY_FILTER_USE_BOTH);
}
/**
* Returns the last (highest) language id from all sites
*
* @return int
*/
protected function getLastLanguageId(): int
{
$lastLanguageId = 0;
foreach (GeneralUtility::makeInstance(SiteFinder::class)->getAllSites() as $site) {
foreach ($site->getAllLanguages() as $language) {
if ($language->getLanguageId() > $lastLanguageId) {
$lastLanguageId = $language->getLanguageId();
}
}
}
return $lastLanguageId;
}
/**
* @return LanguageService
*/
......
......@@ -24,10 +24,10 @@ use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\SiteConfigurationDataGroup;
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
use TYPO3\CMS\Backend\Form\NodeFactory;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Localization\Locales;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
......@@ -83,37 +83,56 @@ class SiteInlineAjaxController extends AbstractFormEngineAjaxController
$childVanillaUid = (int)$inlineFirstPid;
}
$childTableName = $parentConfig['foreign_table'];
$defaultDatabaseRow = [];
if ($childTableName === 'site_language') {
// Feed new site_language row with data from sys_language record if possible
if ($childChildUid > 0) {
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_language');
$queryBuilder->getRestrictions()->removeByType(HiddenRestriction::class);
$row = $queryBuilder->select('*')->from('sys_language')
->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($childChildUid, \PDO::PARAM_INT)))
->execute()->fetch();
if (empty($row)) {
throw new \RuntimeException('Referenced sys_language row not found', 1521783937);
}
if (!empty($row['language_isocode'])) {
$defaultDatabaseRow['iso-639-1'] = $row['language_isocode'];
$defaultDatabaseRow['base'] = '/' . $row['language_isocode'] . '/';
$locales = GeneralUtility::makeInstance(Locales::class);
$allLanguages = $locales->getLanguages();
if (isset($allLanguages[$row['language_isocode']])) {
$defaultDatabaseRow['typo3Language'] = $row['language_isocode'];
if ($childChildUid !== null) {
$language = $this->getLanguageById($childChildUid);
if ($language !== null) {
$defaultDatabaseRow['languageId'] = $language->getLanguageId();
$defaultDatabaseRow['locale'] = $language->getLocale();
if ($language->getTitle() !== '') {
$defaultDatabaseRow['title'] = $language->getTitle();
}
if ($language->getTypo3Language() !== '') {
$locales = GeneralUtility::makeInstance(Locales::class);
$allLanguages = $locales->getLanguages();
if (isset($allLanguages[$language->getTypo3Language()])) {
$defaultDatabaseRow['typo3Language'] = $language->getTypo3Language();
}
}
if ($language->getTwoLetterIsoCode() !== '') {
$defaultDatabaseRow['iso-639-1'] = $language->getTwoLetterIsoCode();
if ($language->getBase()->getPath() !== '/') {
$defaultDatabaseRow['base'] = '/' . $language->getTwoLetterIsoCode() . '/';
}
}
if ($language->getNavigationTitle() !== '') {
$defaultDatabaseRow['navigationTitle'] = $language->getNavigationTitle();
}
if ($language->getHreflang() !== '') {
$defaultDatabaseRow['hreflang'] = $language->getHreflang();
}
if ($language->getDirection() !== '') {
$defaultDatabaseRow['direction'] = $language->getDirection();
}
if (strpos($language->getFlagIdentifier(), 'flags-') === 0) {
$flagIdentifier = str_replace('flags-', '', $language->getFlagIdentifier());
$defaultDatabaseRow['flag'] = ($flagIdentifier === 'multiple') ? 'global' : $flagIdentifier;
}
} elseif ($childChildUid !== 0) {
// In case no language could be found for $childChildUid and
// its value is not "0", which is a special case as the default
// language is added automatically, throw a custom exception.
throw new \RuntimeException('Referenced language not found', 1521783937);
}
if (!empty($row['flag']) && $row['flag'] === 'multiple') {
$defaultDatabaseRow['flag'] = 'global';
} elseif (!empty($row)) {
$defaultDatabaseRow['flag'] = $row['flag'];
}
if (!empty($row['title'])) {
$defaultDatabaseRow['title'] = $row['title'];
}
} else {
// Set new childs' UID to PHP_INT_MAX, as this is the placeholder UID for
// new records, created with the "Create new" button. This is necessary
// as we use the "inline selector" mode which usually does not allow
// to create new records besides the ones, defined in the selector.
// The correct UID will then be calculated by the controller.
$childChildUid = PHP_INT_MAX;
}
}
......@@ -140,7 +159,7 @@ class SiteInlineAjaxController extends AbstractFormEngineAjaxController
}
$childData = $formDataCompiler->compile($formDataCompilerInput);
if ($parentConfig['foreign_selector'] && $parentConfig['appearance']['useCombination']) {
if (($parentConfig['foreign_selector'] ?? false) && ($parentConfig['appearance']['useCombination'] ?? false)) {
throw new \RuntimeException('useCombination not implemented in sites module', 1522493094);
}
......@@ -201,6 +220,7 @@ class SiteInlineAjaxController extends AbstractFormEngineAjaxController
],
],
],
'uid' => $parent['uid'],
'tableName' => $parent['table'],
'inlineFirstPid' => $inlineFirstPid,
// Hand over given original return url to compile stack. Needed if inline children compile links to
......@@ -271,7 +291,7 @@ class SiteInlineAjaxController extends AbstractFormEngineAjaxController
// values of the current parent element
// it is always a string either an id or new...
'inlineParentUid' => $parentData['databaseRow']['uid'],
'inlineParentUid' => $parentData['uid'],
'inlineParentTableName' => $parentData['tableName'],
'inlineParentFieldName' => $parentFieldName,
......@@ -280,7 +300,7 @@ class SiteInlineAjaxController extends AbstractFormEngineAjaxController
'inlineTopMostParentTableName' => $inlineTopMostParent['table'],
'inlineTopMostParentFieldName' => $inlineTopMostParent['field'],
];
if ($parentConfig['foreign_selector'] && $parentConfig['appearance']['useCombination']) {
if (($parentConfig['foreign_selector'] ?? false) && ($parentConfig['appearance']['useCombination'] ?? false)) {
throw new \RuntimeException('useCombination not implemented in sites module', 1522493095);
}
return $formDataCompiler->compile($formDataCompilerInput);
......@@ -375,4 +395,24 @@ class SiteInlineAjaxController extends AbstractFormEngineAjaxController
}
return null;
}
/**
* Find a site language by id. This will return the first occurrence of a
* language, even if the same language is used in other site configurations.
*
* @param int $languageId
* @return SiteLanguage|null
*/
protected function getLanguageById(int $languageId): ?SiteLanguage
{
foreach (GeneralUtility::makeInstance(SiteFinder::class)->getAllSites() as $site) {
foreach ($site->getAllLanguages() as $language) {
if ($languageId === $language->getLanguageId()) {
return $language;
}
}
}
return null;
}
}
<?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\Form\Container;
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
use TYPO3\CMS\Backend\Form\NodeFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Fluid\View\StandaloneView;
/**
* Site languages entry container
*
* @internal This container is only used in the site configuration module and is not public API
*/
class SiteLanguageContainer extends AbstractContainer
{
private const FOREIGN_TABLE = 'site_language';
private const FOREIGN_FIELD = 'languageId';
protected array $inlineData;
protected InlineStackProcessor $inlineStackProcessor;
/**
* Default field information enabled for this element.
*
* @var array
*/
protected $defaultFieldInformation = [
'tcaDescription' => [
'renderType' => 'tcaDescription',
],
];
/**
* Container objects give $nodeFactory down to other containers.
*
* @param NodeFactory $nodeFactory
* @param array $data
*/
public function __construct(NodeFactory $nodeFactory, array $data)
{
parent::__construct($nodeFactory, $data);
$this->inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class);
}
public function render(): array
{
$this->inlineData = $this->data['inlineData'];
$this->inlineStackProcessor->initializeByGivenStructure($this->data['inlineStructure']);
$row = $this->data['databaseRow'];
$parameterArray = $this->data['parameterArray'];
$config = $parameterArray['fieldConf']['config'];
$resultArray = $this->initializeResultArray();
// Add the current inline job to the structure stack
$this->inlineStackProcessor->pushStableStructureItem([
'table' => $this->data['tableName'],
'uid' => $row['uid'],
'field' => $this->data['fieldName'],
'config' => $config,
]);
// Hand over original returnUrl to SiteInlineAjaxController. Needed if opening for instance a
// nested element in a new view to then go back to the original returnUrl and not the url of
// the site inline ajax controller.
$config['originalReturnUrl'] = $this->data['returnUrl'];
// e.g. data[site][1][languages]
$nameForm = $this->inlineStackProcessor->getCurrentStructureFormPrefix();
// e.g. data-0-site-1-languages
$nameObject = $this->inlineStackProcessor->getCurrentStructureDomObjectIdPrefix($this->data['inlineFirstPid']);
// e.g. array('table' => 'site', 'uid' => '1', 'field' => 'languages', 'config' => array())
$top = $this->inlineStackProcessor->getStructureLevel(0);
$this->inlineData['config'][$nameObject] = [
'table' => self::FOREIGN_TABLE
];
$configJson = (string)json_encode($config);
$this->inlineData['config'][$nameObject . '-' . self::FOREIGN_TABLE] = [
'min' => $config['minitems'],
'max' => $config['maxitems'],
'sortable' => false,
'top' => [
'table' => $top['table'],
'uid' => $top['uid']
],
'context' => [
'config' => $configJson,
'hmac' => GeneralUtility::hmac($configJson, 'InlineContext'),
],
];
$this->inlineData['nested'][$nameObject] = $this->data['tabAndInlineStack'];
$uniqueIds = [];
foreach ($parameterArray['fieldConf']['children'] as $children) {
$value = (int)($children['databaseRow'][self::FOREIGN_FIELD]['0'] ?? 0);
if (isset($children['databaseRow']['uid'])) {
$uniqueIds[$children['databaseRow']['uid']] = $value;
}
}
$uniquePossibleRecords = $config['uniquePossibleRecords'] ?? [];
$possibleRecordsUidToTitle = [];
foreach ($uniquePossibleRecords as $possibleRecord) {
$possibleRecordsUidToTitle[$possibleRecord[1]] = $possibleRecord[0];
}
$this->inlineData['unique'][$nameObject . '-' . self::FOREIGN_TABLE] = [
// Usually "max" would the the number of possible records. However, since
// we also allow new languages to be created, we just use the maxitems value.
'max' => $config['maxitems'],
// "used" must be a string array
'used' => array_map('strval', $uniqueIds),
'table' => self::FOREIGN_TABLE,
'elTable' => self::FOREIGN_TABLE,
'field' => self::FOREIGN_FIELD,
'possible' => $possibleRecordsUidToTitle,
];
$resultArray['inlineData'] = $this->inlineData;
$fieldInformationResult = $this->renderFieldInformation();
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
$selectorOptions = $childRecordUids = $childHtml = [];
foreach ($config['uniquePossibleRecords'] ?? [] as $record) {
// Do not add the PHP_INT_MAX placeholder or already configured languages
if ($record[1] !== PHP_INT_MAX && !in_array($record[1], $uniqueIds, true)) {
$selectorOptions[] = ['value' => (string)$record[1], 'label' => (string)$record[0]];
}
}
foreach ($this->data['parameterArray']['fieldConf']['children'] as $children) {
$children['inlineParentUid'] = $row['uid'];
$children['inlineFirstPid'] = $this->data['inlineFirstPid'];
$children['inlineParentConfig'] = $config;
$children['inlineData'] = $this->inlineData;
$children['inlineStructure'] = $this->inlineStackProcessor->getStructure();
$children['inlineExpandCollapseStateArray'] = $this->data['inlineExpandCollapseStateArray'];
$children['renderType'] = 'inlineRecordContainer';
$childResult = $this->nodeFactory->create($children)->render();
$childHtml[] = $childResult['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $childResult, false);
if (isset($children['databaseRow']['uid'])) {
$childRecordUids[] = $children['databaseRow']['uid'];
}
}