Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Update l10n_state if translated values do not match #98

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<?php

declare(strict_types=1);

namespace Lolli\Dbdoctor\HealthCheck;

/*
* 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!
*/

use Lolli\Dbdoctor\Exception\NoSuchTableException;
use Lolli\Dbdoctor\Helper\RecordsHelper;
use Lolli\Dbdoctor\Helper\TableHelper;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Utility\GeneralUtility;

/**
* Handle translated records where the field has allowLanguageSynchronization=1, the l10n_state has "Value of default
* language" but the value differs from record of default language.
*/
final class TcaTablesTranslatedWithAllowLanguageSynchronization extends AbstractHealthCheck implements HealthCheckInterface
{
public function header(SymfonyStyle $io): void
{
$io->section('Scan for translated records which should have value of default language');
$this->outputClass($io);
$this->outputTags($io, self::TAG_SOFT_DELETE, self::TAG_REMOVE, self::TAG_WORKSPACE_REMOVE);
$io->text([
'If a field has the TCA setting allowLanguageSynchronization=1 it is possible for translated records to use',
'the value of the default language.',
'This check finds translated records with different values and l10n_state="parent".',
'and sets the l10n_state to "custom" for these fields.',
]);
}

protected function getAffectedRecords(): array
{
/** @var TableHelper $tableHelper */
$tableHelper = $this->container->get(TableHelper::class);

$affectedRows = [];
foreach ($this->tcaHelper->getNextFieldWithAllowLanguageSynchronization() as $tableFields) {
$table = $tableFields['tableName'];
$field = $tableFields['fieldName'];
if (!$tableHelper->tableExistsInDatabase($table)) {
throw new NoSuchTableException('Table "' . $table . '" does not exist.');
}
// transOrigPointerField, e.g. l18n_parent
$transOrigPointerField = $this->tcaHelper->getTranslationParentField($table);
if (!$transOrigPointerField) {
continue;
}
// languageField, e.g. sys_language_uid
$languageField = $this->tcaHelper->getLanguageField($table);
if (!$languageField) {
continue;
}
// l10n_state
$translationStateField = 'l10n_state';

$selectFields = [
$table . '.uid AS uid',
$table . '.pid AS pid',
$table . '.l10n_state AS l10n_state',
$table . '.' . $field . ' AS ' . $field,
$table . '.' . $translationStateField . ' AS ' . $translationStateField,
$table . '.' . $languageField,
$table . '.' . $transOrigPointerField,
'parent.uid AS uid2',
'parent.' . $field . ' AS ' . $field . '2',
];

$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
// Do not consider soft-deleted records
$queryBuilder->getRestrictions()->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
// get all translated records with a connected default language record and different value for field $field
$result = $queryBuilder->select(...$selectFields)
->from($table)
->innerJoin(
$table,
$table,
'parent',
$queryBuilder->expr()->eq(
$table . '.' . $transOrigPointerField,
$queryBuilder->quoteIdentifier('parent.uid')
)
)
->where(
$queryBuilder->expr()->neq(
$table . '.' . $languageField,
$queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
),
$queryBuilder->expr()->neq(
$table . '.' . $transOrigPointerField,
$queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
),
$queryBuilder->expr()->neq(
$table . '.' . $field,
$queryBuilder->quoteIdentifier('parent.' . $field)
),
)
->orderBy($table . '.uid')
->executeQuery();
while ($row = $result->fetchAssociative()) {
/** @var array<string, int|string> $row */
$state = $this->getL10nStateForField((string)($row[$translationStateField] ?? ''), $field, 'parent');
if ($state === 'parent') {
$uid = (int)($row['uid']);
// if we are handling several fields in one record, we handle them all in one scoop
// (otherwise subsequent changes would overwrite previous changes in l10n_state)
if (isset($affectedRows[$table][$uid])) {
$affectedRows[$table][$uid]['_fieldNames'] .= ',' . $field;
$affectedRows[$table][$uid]['_reasonBroken'] = sprintf(
'Value for fields %s differ from language parent',
$affectedRows[$table][$uid]['_fieldNames']
);
} else {
$affectedRow = [];
$affectedRow['uid'] = $uid;
$affectedRow['pid'] = (int)($row['pid']);
$affectedRow['l10n_state'] = (string)($row['l10n_state'] ?? '');
$affectedRow['_fieldNames'] = $field;
$affectedRow['_reasonBroken'] = sprintf(
'Value for field %s differs from language parent',
$field
);
$affectedRows[$table][$uid] = $affectedRow;
}
}
}
}
return $affectedRows;
}

/**
* @param string $l10nState
* @param array<int,string> $fields
* @return string
*/
protected function addFieldsToL10nState(string $l10nState, array $fields, string $value): string
{
/** @var array<string,string> $array */
$array = \json_decode($l10nState, true) ?: [];
foreach ($fields as $field) {
$array[$field] = $value;
}
return (string)(\json_encode($array) ?: $l10nState);
}

protected function getL10nStateForField(string $l10nState, string $field, string $default): string
{
/** @var array<string,string> $array */
$array = \json_decode($l10nState, true) ?: [];
return (string)($array[$field] ?? $default);
}

protected function processRecords(SymfonyStyle $io, bool $simulate, array $affectedRecords): void
{
/** @var RecordsHelper $recordsHelper */
$recordsHelper = $this->container->get(RecordsHelper::class);

foreach ($affectedRecords as $table => $rows) {
foreach ($rows as $row) {
$uid = (int)$row['uid'];
$fields = explode(',', (string)$row['_fieldNames']);
$newL10nState = $this->addFieldsToL10nState((string)($row['l10n_state'] ?? ''), $fields, 'custom');
$updateField = [
'l10n_state' => [
'value' => $newL10nState,
'type' => \PDO::PARAM_STR,
],
];
$this->updateSingleTcaRecord($io, $simulate, $recordsHelper, $table, $uid, $updateField);
}
}
}

protected function recordDetails(SymfonyStyle $io, array $affectedRecords): void
{
foreach ($affectedRecords as $tableName => $rows) {
$extraDbFields = [
(string)$rows[0]['_fieldName'],
];
$this->outputRecordDetails($io, [$tableName => $rows], '_reasonBroken', [], $extraDbFields);
}
}
}
1 change: 1 addition & 0 deletions Classes/HealthFactory/HealthFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ final class HealthFactory implements HealthFactoryInterface
HealthCheck\InlineForeignFieldChildrenParentDeleted::class,
// @todo: Maybe that's not a good position when we start scanning for records translated more than once (issue #9)?
HealthCheck\InlineForeignFieldChildrenParentLanguageDifferent::class,
HealthCheck\TcaTablesTranslatedWithAllowLanguageSynchronization::class,
];

private ContainerInterface $container;
Expand Down
27 changes: 27 additions & 0 deletions Classes/Helper/TcaHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,33 @@ public function getNextLanguageSourceAwareTcaTable(array $ignoreTables = []): it
}
}

/**
* @param array<int, string> $ignoreTables
* @return iterable<array<string, string>>
*/
public function getNextFieldWithAllowLanguageSynchronization(array $ignoreTables = []): iterable
{
$this->verifyTcaIsArray();
$tablesFields = [];
foreach ($GLOBALS['TCA'] as $tablename => $config) {
if (in_array($tablename, $ignoreTables)) {
continue;
}
foreach (($config['columns'] ?? []) as $fieldName => $columnConfig) {
$allowLanguageSynchronization = (int)($columnConfig['config']['behaviour']['allowLanguageSynchronization'] ?? 0);
if ($allowLanguageSynchronization) {
$tablesFields[] = [
'tableName' => $tablename,
'fieldName' => $fieldName,
];
}
}
}
foreach ($tablesFields as $row) {
yield $row;
}
}

/**
* @param array<int, string> $ignoreTables
* @return iterable<array<string, string>>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"pages"
,"uid","pid","url","author","sys_language_uid","l10n_parent","l10n_state"
# Test with empty l10n_state
,1,0,"https://example.org","",0,0,""
,2,0,"","",1,1,"{""url"":""custom""}"
# Test with l10n_state containing values for other field
,3,0,"https://example.org","",0,0,""
,4,0,"","",1,3,"{""author"":""custom"",""url"":""custom""}"
# Test with l10n_state: parent
,5,0,"https://example.org","",0,0,""
,6,0,"","",1,5,"{""url"":""custom""}"
# Test with no changes necessary
,7,0,"https://example.org","",0,0,""
,8,0,"https://example.org","",1,7,""
# Test with several fields
,9,0,"https://example.org","Author",0,0,""
,10,0,"","",1,9,"{""url"":""custom"",""author"":""custom""}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"pages"
,"uid","pid","url","author","sys_language_uid","l10n_parent","l10n_state"
# Test with empty l10n_state and field "url"
,1,0,"https://example.org","",0,0,""
,2,0,"","",1,1,""
# Test with l10n_state containing values for other field
,3,0,"https://example.org","",0,0,""
,4,0,"","",1,3,"{""author"":""custom""}"
# Test with l10n_state: parent
,5,0,"https://example.org","",0,0,""
,6,0,"","",1,5,"{""url"":""parent""}"
# Test with no changes necessary
,7,0,"https://example.org","",0,0,""
,8,0,"https://example.org","",1,7,""
# Test with several fields
,9,0,"https://example.org","Author",0,0,""
,10,0,"","",1,9,""
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Lolli\Dbdoctor\Tests\Functional\HealthCheck;

/*
* 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!
*/

use Lolli\Dbdoctor\HealthCheck\HealthCheckInterface;
use Lolli\Dbdoctor\HealthCheck\TcaTablesTranslatedWithAllowLanguageSynchronization;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;

class TcaTablesTranslatedWithAllowLanguageSynchronizationTest extends FunctionalTestCase
{
protected array $testExtensionsToLoad = [
'typo3conf/ext/dbdoctor',
];

/**
* @test
*/
public function fixBrokenRecords(): void
{
$this->importCSVDataSet(__DIR__ . '/../Fixtures/TcaTablesTranslatedWithAllowLanguageSynchronizationTestImport.csv');
$io = $this->getMockBuilder(SymfonyStyle::class)->disableOriginalConstructor()->getMock();
$io->expects(self::atLeastOnce())->method('warning');
/** @var TcaTablesTranslatedWithAllowLanguageSynchronization $subject */
$subject = $this->get(TcaTablesTranslatedWithAllowLanguageSynchronization::class);
$subject->handle($io, HealthCheckInterface::MODE_EXECUTE, '');
$this->assertCSVDataSet(__DIR__ . '/../Fixtures/TcaTablesTranslatedWithAllowLanguageSynchronizationTestFixed.csv');
}
}