From b00bc4bd822adb037c6a57726d96acd99153bab8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gordon=20Br=C3=BCggemann?= <gordon.brueggemann@gmx.de>
Date: Thu, 11 Feb 2021 22:22:20 +0100
Subject: [PATCH] [FEATURE] Enable multi-level language fallback for rendered
 content

This change enables functionality of every content
to use the fallback chain to loop over each fallback language
to check for a fallback content in a different language ID.

This is now in line with the page resolving fallback handling
and finally completes the options within site configuration
"fallbackChain".

This works in Extbase and TypoScript environments.

Resolves: #88137
Releases: main
Change-Id: I987a296c6e483967d497bf59e71b8c5cbcd54938
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/67893
Tested-by: Benni Mack <benni@typo3.org>
Tested-by: Oliver Bartsch <bo@cedev.de>
Tested-by: core-ci <typo3@b13.com>
Reviewed-by: Benni Mack <benni@typo3.org>
Reviewed-by: Oliver Bartsch <bo@cedev.de>
---
 .../Domain/Repository/PageRepository.php      |  58 ++++++-
 ...lFallbackForContentInFrontendRendering.rst |  58 +++++++
 .../Generic/Storage/Typo3DbBackendTest.php    |   2 +
 .../LocalizedSiteContentRenderingTest.php     | 161 +++++++++---------
 4 files changed, 191 insertions(+), 88 deletions(-)
 create mode 100644 typo3/sysext/core/Documentation/Changelog/12.2/Feature-88137-Multi-levelFallbackForContentInFrontendRendering.rst

diff --git a/typo3/sysext/core/Classes/Domain/Repository/PageRepository.php b/typo3/sysext/core/Classes/Domain/Repository/PageRepository.php
index b5c2020cc441..b9aa25ea2072 100644
--- a/typo3/sysext/core/Classes/Domain/Repository/PageRepository.php
+++ b/typo3/sysext/core/Classes/Domain/Repository/PageRepository.php
@@ -365,8 +365,7 @@ class PageRepository implements LoggerAwareInterface
     /**
      * Master helper method to overlay a record to a language.
      *
-     * Be aware that for pages the languageId is taken, and for all other records the contentId.
-     * This might change through a feature switch in the future.
+     * Be aware that for pages the languageId is taken, and for all other records the contentId of the Aspect is used.
      *
      * @param string $table the name of the table, should be a TCA table with localization enabled
      * @param array $originalRow the current (full-fletched) record.
@@ -398,10 +397,57 @@ class PageRepository implements LoggerAwareInterface
         $localizedRecord = null;
         if ($languageAspect->doOverlays()) {
             $attempted = true;
-            if ($table === 'pages') {
-                $localizedRecord = $this->getPageOverlay($originalRow, $languageAspect);
+            // Mixed = if nothing is available in the selected language, try the fallbacks
+            // Fallbacks work as follows:
+            // 1. We have a default language record and then start doing overlays (= the basis for fallbacks)
+            // 2. Check if the actual requested language version is available in the DB (language=3 = canadian-french)
+            // 3. If not, we check the next language version in the chain (e.g. language=2 = french) and so forth until we find a record
+            if ($languageAspect->getOverlayType() === LanguageAspect::OVERLAYS_MIXED) {
+                $languageChain = $this->getLanguageFallbackChain($languageAspect);
+                $languageChain = array_reverse($languageChain);
+                if ($table === 'pages') {
+                    $result = $this->getPageOverlay(
+                        $originalRow,
+                        new LanguageAspect($languageAspect->getId(), $languageAspect->getId(), LanguageAspect::OVERLAYS_MIXED, $languageChain)
+                    );
+                    if (!empty($result)) {
+                        $localizedRecord = $result;
+                    }
+                } else {
+                    $languageChain = array_merge($languageChain, [$languageAspect->getContentId()]);
+                    // Loop through each (fallback) language and see if there is a record
+                    // However, we do not want to preserve the "originalRow", that's why we set the option to "OVERLAYS_ON"
+                    while (($languageId = array_pop($languageChain)) !== null) {
+                        $result = $this->getRecordOverlay(
+                            $table,
+                            $originalRow,
+                            new LanguageAspect($languageId, $languageId, LanguageAspect::OVERLAYS_ON)
+                        );
+                        // If an overlay is found, return it
+                        if (is_array($result)) {
+                            $localizedRecord = $result;
+                            break;
+                        }
+                    }
+                    if ($localizedRecord === null) {
+                        // If nothing was found, we set the localized record to the originalRow to simulate
+                        // that the default language is "kept" (we want fallback to default language).
+                        // Note: Most installations might have "type=fallback" set but do not set the default language
+                        // as fallback. In the future - once we want to get rid of the magic "default language",
+                        // this needs to behave different, and the "pageNotFound" special handling within fallbacks should be removed
+                        // and we need to check explicitly on in_array(0, $languageAspect->getFallbackChain())
+                        // However, getPageOverlay() a few lines above also returns the "default language page" as well.
+                        $localizedRecord = $originalRow;
+                    }
+                }
             } else {
-                $localizedRecord = $this->getRecordOverlay($table, $originalRow, $languageAspect);
+                // The option to hide records if they were not explicitly selected, was chosen (OVERLAYS_ON/WITH_FLOATING)
+                // in the language configuration. So, here no changes are done.
+                if ($table === 'pages') {
+                    $localizedRecord = $this->getPageOverlay($originalRow, $languageAspect);
+                } else {
+                    $localizedRecord = $this->getRecordOverlay($table, $originalRow, $languageAspect);
+                }
             }
         }
 
@@ -740,7 +786,7 @@ class PageRepository implements LoggerAwareInterface
                 }
             }
         }
-        return $row;
+        return is_array($row) ? $row : null;
     }
 
     /************************************************
diff --git a/typo3/sysext/core/Documentation/Changelog/12.2/Feature-88137-Multi-levelFallbackForContentInFrontendRendering.rst b/typo3/sysext/core/Documentation/Changelog/12.2/Feature-88137-Multi-levelFallbackForContentInFrontendRendering.rst
new file mode 100644
index 000000000000..d9018230c14f
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/12.2/Feature-88137-Multi-levelFallbackForContentInFrontendRendering.rst
@@ -0,0 +1,58 @@
+.. include:: /Includes.rst.txt
+
+.. _feature-88137-1673993076:
+
+========================================================================
+Feature: #88137 - Multi-level fallback for content in frontend rendering
+========================================================================
+
+See :issue:`88137`
+
+Description
+===========
+
+TYPO3's Site Handling was introduced in TYPO3 v9 and allows to define a
+"Fallback Type".
+
+A fallback type allows to define the behavior of how pages and the content
+should be fetched from the database when rendering a page in the frontend.
+
+The option "strict" only renders content which was explicitly translated or
+created in this defined language, and keeps the sorting behavior of the
+default language.
+
+The option "free" does not consider the default language or its sorting,
+and only directly fetches content of the given Language ID.
+
+The option "fallback" allows to define a fallback chain of other languages.
+When a certain page in the given language is not available or created, TYPO3
+first checks the fallback chain if a page is available in one of the languages
+of the fallback chain.
+
+A common scenario is this:
+* German (Austria) - Language = 2
+* German (Germany) - Language = 1
+* English (Default) - Language = 0
+
+TYPO3 now can deal with the language chain in fallback mode not only for pages,
+but also for any kind of content.
+
+
+Impact
+======
+
+When working in a scenario with "fallback" and multiple languages in the fallback
+chain, TYPO3 now checks for each content if the target language is available,
+and then checks for the same content if it is translated in the language of the
+fallback chain (example above in "German (Germany)"), before falling back to
+the default language - which was the behavior until now.
+
+The language chain processing works with fallback mode (a.k.a. "overlays in mixed mode"),
+both in TypoScript and Extbase code. Under the hood, the method
+:php:`PageRepository->getLanguageOverlay()` is responsible for the chaining.
+
+Current limitations:
+* Content fallback only works in fallbackType=fallback
+* Content fallback always stops at the default language (as this was the previous behavior)
+
+.. index:: Frontend, PHP-API, TypoScript, ext:core
diff --git a/typo3/sysext/extbase/Tests/Functional/Persistence/Generic/Storage/Typo3DbBackendTest.php b/typo3/sysext/extbase/Tests/Functional/Persistence/Generic/Storage/Typo3DbBackendTest.php
index 6f6780646e81..26eee5252e8d 100644
--- a/typo3/sysext/extbase/Tests/Functional/Persistence/Generic/Storage/Typo3DbBackendTest.php
+++ b/typo3/sysext/extbase/Tests/Functional/Persistence/Generic/Storage/Typo3DbBackendTest.php
@@ -21,6 +21,7 @@ use ExtbaseTeam\BlogExample\Domain\Model\Tag;
 use ExtbaseTeam\BlogExample\Domain\Repository\BlogRepository;
 use ExtbaseTeam\BlogExample\Domain\Repository\PostRepository;
 use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Context\LanguageAspect;
 use TYPO3\CMS\Core\Context\WorkspaceAspect;
 use TYPO3\CMS\Core\Core\SystemEnvironmentBuilder;
 use TYPO3\CMS\Core\Http\ServerRequest;
@@ -71,6 +72,7 @@ class Typo3DbBackendTest extends FunctionalTestCase
         $blogRepository = $this->get(BlogRepository::class);
         $context = new Context([
             'workspace' => new WorkspaceAspect(1),
+            'language' => new LanguageAspect(0, 0),
         ]);
         GeneralUtility::setSingletonInstance(Context::class, $context);
         $querySettings = new Typo3QuerySettings($context, $this->get(ConfigurationManagerInterface::class));
diff --git a/typo3/sysext/frontend/Tests/Functional/Rendering/LocalizedSiteContentRenderingTest.php b/typo3/sysext/frontend/Tests/Functional/Rendering/LocalizedSiteContentRenderingTest.php
index feaa9066354b..16fd8a0e789d 100644
--- a/typo3/sysext/frontend/Tests/Functional/Rendering/LocalizedSiteContentRenderingTest.php
+++ b/typo3/sysext/frontend/Tests/Functional/Rendering/LocalizedSiteContentRenderingTest.php
@@ -87,7 +87,7 @@ use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\ResponseContent;
  *
  * LanguageAspect
  * -> doOverlays()
- *    whether the the overlay logic should be applied
+ *    whether the overlay logic should be applied
  * -> getLanguageId()
  *    the language that was originally requested
  * -> getContentId()
@@ -681,113 +681,110 @@ class LocalizedSiteContentRenderingTest extends AbstractDataHandlerActionTestCas
         self::assertEquals($statusCode, $response->getStatusCode());
     }
 
-    public function contentOnPartiallyTranslatedPageDataProvider(): array
+    public function contentOnPartiallyTranslatedPageDataProvider(): \Generator
     {
         //Expected behaviour:
         //Setting sys_language_mode to different values doesn't influence the result as the requested page is translated to Polish,
         //Page title is always [PL]Page, and both languageId/contentId are always 3
-        return [
-            [
-                'languageConfiguration' => [
-                    'fallbackType' => 'free',
-                ],
-                'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', '[PL] Without default language'],
+        yield 'free' => [
+            'languageConfiguration' => [
                 'fallbackType' => 'free',
-                'fallbackChain' => 'pageNotFound',
-                'overlayMode' => 'off',
             ],
-            [
-                'languageConfiguration' => [
-                    'fallbackType' => 'free',
-                    'fallbackChain' => ['EN'],
-                ],
-                'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', '[PL] Without default language'],
+            'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', '[PL] Without default language'],
+            'fallbackType' => 'free',
+            'fallbackChain' => 'pageNotFound',
+            'overlayMode' => 'off',
+        ];
+        yield 'free with fallback' => [
+            'languageConfiguration' => [
                 'fallbackType' => 'free',
-                'fallbackChain' => '0,pageNotFound',
-                'overlayMode' => 'off',
+                'fallbackChain' => ['EN'],
             ],
-            [
-                'languageConfiguration' => [
-                    'fallbackType' => 'free',
-                    'fallbackChain' => ['DK', 'EN'],
-                ],
-                'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', '[PL] Without default language'],
+            'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', '[PL] Without default language'],
+            'fallbackType' => 'free',
+            'fallbackChain' => '0,pageNotFound',
+            'overlayMode' => 'off',
+        ];
+        yield 'free with multiple fallbacks' => [
+            'languageConfiguration' => [
                 'fallbackType' => 'free',
-                'fallbackChain' => '1,0,pageNotFound',
-                'overlayMode' => 'off',
+                'fallbackChain' => ['DK', 'EN'],
             ],
-            // Expected behaviour:
-            // Not translated element #2 is shown because sys_language_overlay = 1 (with sys_language_overlay = hideNonTranslated, it would be hidden)
-            [
-                'languageConfiguration' => [
-                    'fallbackType' => 'fallback',
-                    'fallbackChain' => ['EN'],
-                ],
-                'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', 'Regular Element #2', 'Regular Element #3'],
+            'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', '[PL] Without default language'],
+            'fallbackType' => 'free',
+            'fallbackChain' => '1,0,pageNotFound',
+            'overlayMode' => 'off',
+        ];
+        // Expected behaviour:
+        // Not translated element #2 is shown because sys_language_overlay = 1 (with sys_language_overlay = hideNonTranslated, it would be hidden)
+        yield 'fallback to EN' => [
+            'languageConfiguration' => [
                 'fallbackType' => 'fallback',
-                'fallbackChain' => '0,pageNotFound',
-                'overlayMode' => 'mixed',
+                'fallbackChain' => ['EN'],
             ],
-            // Expected behaviour:
-            // Element #3 is not translated in PL and it is translated in DK. It's not shown as content_fallback is not related to single CE level
-            // but on page level - and this page is translated to Polish, so no fallback is happening
-            [
-                'languageConfiguration' => [
-                    'fallbackType' => 'fallback',
-                    'fallbackChain' => ['DK', 'EN'],
-                ],
-                'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', 'Regular Element #2', 'Regular Element #3'],
+            'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', 'Regular Element #2', 'Regular Element #3'],
+            'fallbackType' => 'fallback',
+            'fallbackChain' => '0,pageNotFound',
+            'overlayMode' => 'mixed',
+        ];
+        // Expected behaviour:
+        // Element #3 is not translated in PL, but it is translated in DK. The DK version is shown as it has a fallback chain defined.
+        yield 'fallback with multiple languages' => [
+            'languageConfiguration' => [
                 'fallbackType' => 'fallback',
-                'fallbackChain' => '1,0,pageNotFound',
-                'overlayMode' => 'mixed',
+                'fallbackChain' => ['DK', 'EN'],
             ],
-            [
-                'languageConfiguration' => [
-                    'fallbackType' => 'fallback',
-                    'fallbackChain' => [],
-                ],
-                'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', 'Regular Element #2', 'Regular Element #3'],
+            'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', 'Regular Element #2', '[Translate to Dansk:] Regular Element #3'],
+            'fallbackType' => 'fallback',
+            'fallbackChain' => '1,0,pageNotFound',
+            'overlayMode' => 'mixed',
+        ];
+        yield 'fallback but without languages' => [
+            'languageConfiguration' => [
                 'fallbackType' => 'fallback',
-                'fallbackChain' => 'pageNotFound',
-                'overlayMode' => 'mixed',
+                'fallbackChain' => [],
             ],
-            // Expected behaviour:
-            // Non translated default language elements are not shown, because of hideNonTranslated
-            [
-                'languageConfiguration' => [
-                    'fallbackType' => 'strict',
-                    'fallbackChain' => ['EN'],
-                ],
-                'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', '[PL] Without default language'],
+            'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', 'Regular Element #2', 'Regular Element #3'],
+            'fallbackType' => 'fallback',
+            'fallbackChain' => 'pageNotFound',
+            'overlayMode' => 'mixed',
+        ];
+        // Expected behaviour:
+        // Non translated default language elements are not shown, because of hideNonTranslated
+        yield 'strict with fallback to default' => [
+            'languageConfiguration' => [
                 'fallbackType' => 'strict',
-                'fallbackChain' => '0,pageNotFound',
-                'overlayMode' => 'includeFloating',
+                'fallbackChain' => ['EN'],
             ],
-            [
-                'languageConfiguration' => [
-                    'fallbackType' => 'strict',
-                    'fallbackChain' => ['DK', 'EN'],
-                ],
-                'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', '[PL] Without default language'],
+            'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', '[PL] Without default language'],
+            'fallbackType' => 'strict',
+            'fallbackChain' => '0,pageNotFound',
+            'overlayMode' => 'includeFloating',
+        ];
+        yield 'strict with multiple fallbacks' => [
+            'languageConfiguration' => [
                 'fallbackType' => 'strict',
-                'fallbackChain' => '1,0,pageNotFound',
-                'overlayMode' => 'includeFloating',
+                'fallbackChain' => ['DK', 'EN'],
             ],
-            [
-                'languageConfiguration' => [
-                    'fallbackType' => 'strict',
-                    'fallbackChain' => [],
-                ],
-                'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', '[PL] Without default language'],
+            'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', '[PL] Without default language'],
+            'fallbackType' => 'strict',
+            'fallbackChain' => '1,0,pageNotFound',
+            'overlayMode' => 'includeFloating',
+        ];
+        yield 'strict without fallback' => [
+            'languageConfiguration' => [
                 'fallbackType' => 'strict',
-                'fallbackChain' => 'pageNotFound',
-                'overlayMode' => 'includeFloating',
+                'fallbackChain' => [],
             ],
+            'visibleRecordHeaders' => ['[Translate to Polski:] Regular Element #1', '[PL] Without default language'],
+            'fallbackType' => 'strict',
+            'fallbackChain' => 'pageNotFound',
+            'overlayMode' => 'includeFloating',
         ];
     }
 
     /**
-     * Page uid 89 is translated to to Polish, but not all CE are translated
+     * Page uid 89 is translated to Polish, but not all CE are translated
      *
      * @test
      * @dataProvider contentOnPartiallyTranslatedPageDataProvider
-- 
GitLab