diff --git a/src/Cms/Helpers.php b/src/Cms/Helpers.php index 9e27f426bc..80711f2746 100644 --- a/src/Cms/Helpers.php +++ b/src/Cms/Helpers.php @@ -38,6 +38,13 @@ class Helpers // TODO: switch to true in v6 'plugin-extends-root' => false, + // The `Content\Translation` class keeps a set of methods from the old + // `ContentTranslation` class for compatibility that should no longer be used. + // Some of them can be replaced by using `Version` class methods instead + // (see method comments). `Content\Translation::contentFile` should be avoided + // entirely and has no recommended replacement. + 'translation-methods' => true, + // Passing a single space as value to `Xml::attr()` has been // deprecated. In a future version, passing a single space won't // render an empty value anymore but a single space. diff --git a/src/Content/Translation.php b/src/Content/Translation.php new file mode 100644 index 0000000000..62355a8168 --- /dev/null +++ b/src/Content/Translation.php @@ -0,0 +1,196 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Translation extends ContentTranslation +{ + /** + * Creates a new translation object + */ + public function __construct( + protected ModelWithContent $model, + protected Version $version, + protected Language $language + ) { + } + + /** + * Improve `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Returns the language code of the + * translation + * + * @deprecated 5.0.0 Use `::language()->code()` instead + */ + public function code(): string + { + Helpers::deprecated('`$translation->code()` has been deprecated. Use `$translation->language()->code()` instead.', 'translation-methods'); + return $this->language->code(); + } + + /** + * Returns the translation content + * as plain array + * + * @deprecated 5.0.0 Use `::version()->content()->toArray()` instead + */ + public function content(): array + { + Helpers::deprecated('`$translation->content()->toArray()` has been deprecated. Use `$translation->version()->content()` instead.', 'translation-methods'); + return $this->version->content($this->language)->toArray(); + } + + /** + * Absolute path to the translation content file + * + * @deprecated 5.0.0 + */ + public function contentFile(): string + { + Helpers::deprecated('`$translation->contentFile()` has been deprecated. Please let us know if you have a use case for a replacement.', 'translation-methods'); + return $this->version->contentFile($this->language); + } + + /** + * Creates a new Translation for the given model + * + * @todo Needs to be refactored as soon as Version::create becomes static + * (see https://github.com/getkirby/kirby/pull/6491#discussion_r1652264408) + */ + public static function create( + ModelWithContent $model, + Version $version, + Language $language, + array $fields, + string|null $slug = null + ): static { + // add the custom slug to the fields array + if ($slug !== null) { + $fields['slug'] = $slug; + } + + $version->create($fields, $language); + + return new static( + model: $model, + version: $version, + language: $language, + ); + } + + /** + * Checks if the translation file exists + * + * @deprecated 5.0.0 Use `::version()->exists()` instead + */ + public function exists(): bool + { + Helpers::deprecated('`$translation->exists()` has been deprecated. Use `$translation->version()->exists()` instead.', 'translation-methods'); + return $this->version->exists($this->language); + } + + /** + * Returns the translation code as id + */ + public function id(): string + { + return $this->language->code(); + } + + /** + * Checks if the this is the default translation + * of the model + * + * @deprecated 5.0.0 Use `::language()->isDefault()` instead + */ + public function isDefault(): bool + { + Helpers::deprecated('`$translation->isDefault()` has been deprecated. Use `$translation->language()->isDefault()` instead.', 'translation-methods'); + return $this->language->isDefault(); + } + + /** + * Returns the language + */ + public function language(): Language + { + return $this->language; + } + + /** + * Returns the parent page, file or site object + */ + public function model(): ModelWithContent + { + return $this->model; + } + + /** + * @deprecated 5.0.0 Use `$translation->model()` instead + */ + public function parent(): ModelWithContent + { + throw new Exception('`$translation->parent()` has been deprecated. Please use `$translation->model()` instead'); + } + + /** + * Returns the custom translation slug + */ + public function slug(): string|null + { + return $this->version->content($this->language)->data()['slug'] ?? null; + } + + /** + * Converts the most important translation + * props to an array + */ + public function toArray(): array + { + return [ + 'code' => $this->language->code(), + 'content' => $this->version->content($this->language)->toArray(), + 'exists' => $this->version->exists($this->language), + 'slug' => $this->slug(), + ]; + } + + /** + * @deprecated 5.0.0 Use `$model->version()->update()` instead + */ + public function update(array|null $data = null, bool $overwrite = false): static + { + throw new Exception('`$translation->update()` has been deprecated. Please use `$model->version()->update()` instead'); + } + + /** + * Returns the version + */ + public function version(): Version + { + return $this->version; + } +} diff --git a/src/Content/Translations.php b/src/Content/Translations.php new file mode 100644 index 0000000000..9da4c449d9 --- /dev/null +++ b/src/Content/Translations.php @@ -0,0 +1,79 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Collection<\Kirby\Content\Translation> + */ +class Translations extends Collection +{ + /** + * Creates a new Translations collection from + * an array of translations properties. This is + * used in LabPage::setTranslations to properly + * normalize an array definition. + * + * @todo Needs to be refactored as soon as Version::create becomes static + * (see https://github.com/getkirby/kirby/pull/6491#discussion_r1652264408) + */ + public static function create( + ModelWithContent $model, + Version $version, + array $translations + ): static { + foreach ($translations as $translation) { + Translation::create( + model: $model, + version: $version, + language: Language::ensure($translation['code'] ?? 'default'), + fields: $translation['content'] ?? [], + slug: $translation['slug'] ?? null + ); + } + + return static::load( + model: $model, + version: $version + ); + } + + /** + * Simplifies `Translations::find` by allowing to pass + * Language codes that will be properly validated here. + */ + public function findByKey(string $key): Translation|null + { + return parent::get(Language::ensure($key)->code()); + } + + /** + * Loads all available translations for a given model + */ + public static function load( + ModelWithContent $model, + Version $version + ): static { + $translations = []; + + foreach (Languages::ensure() as $language) { + $translations[] = new Translation( + model: $model, + version: $version, + language: $language + ); + } + + return new static($translations); + } +} diff --git a/tests/Content/TestCase.php b/tests/Content/TestCase.php index cd0e11a39d..7dd816016f 100644 --- a/tests/Content/TestCase.php +++ b/tests/Content/TestCase.php @@ -3,6 +3,7 @@ namespace Kirby\Content; use Kirby\Cms\App; +use Kirby\Data\Data; use Kirby\Filesystem\Dir; use Kirby\TestCase as BaseTestCase; @@ -12,6 +13,66 @@ class TestCase extends BaseTestCase protected $model; + public function assertContentFileExists(string|null $language = null, VersionId|null $versionId = null) + { + $this->assertFileExists($this->contentFile($language, $versionId)); + } + + public function assertContentFileDoesNotExist(string|null $language = null, VersionId|null $versionId = null) + { + $this->assertFileDoesNotExist($this->contentFile($language, $versionId)); + } + + public function contentFile(string|null $language = null, VersionId|null $versionId = null): string + { + return + $this->model->root() . + // add the changes folder + ($versionId?->value() === 'changes' ? '/_changes/' : '/') . + // template + 'article' . + // language code + ($language === null ? '' : '.' . $language) . + '.txt'; + } + + public function createContentMultiLanguage(): array + { + Data::write($fileEN = $this->contentFile('en'), $contentEN = [ + 'title' => 'Title English', + 'subtitle' => 'Subtitle English' + ]); + + Data::write($fileDE = $this->contentFile('de'), $contentDE = [ + 'title' => 'Title Deutsch', + 'subtitle' => 'Subtitle Deutsch' + ]); + + return [ + 'en' => [ + 'content' => $contentEN, + 'file' => $fileEN, + ], + 'de' => [ + 'content' => $contentDE, + 'file' => $fileDE, + ] + ]; + } + + public function createContentSingleLanguage(): array + { + Data::write($file = $this->contentFile(), $content = [ + 'title' => 'Title', + 'subtitle' => 'Subtitle' + ]); + + return [ + 'content' => $content, + 'file' => $file + ]; + } + public function setUp(): void { Dir::make(static::TMP); diff --git a/tests/Content/TranslationTest.php b/tests/Content/TranslationTest.php new file mode 100644 index 0000000000..89a75b89b3 --- /dev/null +++ b/tests/Content/TranslationTest.php @@ -0,0 +1,388 @@ +setUpMultiLanguage(); + + $translationEN = new Translation( + model: $this->model, + version: $this->model->version(), + language: Language::ensure('en') + ); + + $translationDE = new Translation( + model: $this->model, + version: $this->model->version(), + language: Language::ensure('de') + ); + + $this->assertSame('en', $translationEN->language()->code()); + $this->assertSame('en', $translationEN->id()); + + $this->assertSame('de', $translationDE->language()->code()); + $this->assertSame('de', $translationDE->id()); + } + + /** + * @covers ::content + */ + public function testContentMultiLanguage() + { + $this->setUpMultiLanguage(); + + $translationEN = new Translation( + model: $this->model, + version: $this->model->version(), + language: $languageEN = Language::ensure('en') + ); + + $translationDE = new Translation( + model: $this->model, + version: $this->model->version(), + language: $languageDE = Language::ensure('de') + ); + + $expected = $this->createContentMultiLanguage(); + + $this->assertSame($expected['en']['content'], $translationEN->version()->content($languageEN)->toArray()); + $this->assertSame($expected['de']['content'], $translationDE->version()->content($languageDE)->toArray()); + } + + /** + * @covers ::content + */ + public function testContentSingleLanguage() + { + $this->setUpSingleLanguage(); + + $translation = new Translation( + model: $this->model, + version: $this->model->version(), + language: Language::single() + ); + + $expected = $this->createContentSingleLanguage(); + + $this->assertSame($expected['content'], $translation->version()->content()->toArray()); + } + + /** + * @covers ::contentFile + */ + public function testContentFileMultiLanguage() + { + $this->setUpMultiLanguage(); + + $translation = new Translation( + model: $this->model, + version: $this->model->version(), + language: Language::ensure('en') + ); + + $this->assertSame($this->model->root() . '/article.en.txt', $translation->version()->contentFile()); + } + + /** + * @covers ::contentFile + */ + public function testContentFileSingleLanguage() + { + $this->setUpSingleLanguage(); + + $translation = new Translation( + model: $this->model, + version: $this->model->version(), + language: Language::single() + ); + + $this->assertSame($this->model->root() . '/article.txt', $translation->version()->contentFile()); + } + + /** + * @covers ::create + */ + public function testCreate() + { + $this->setUpMultiLanguage(); + + $translation = Translation::create( + model: $this->model, + version: $version = $this->model->version(), + language: $language = Language::ensure('en'), + fields: $content = [ + 'title' => 'Test' + ] + ); + + $this->assertSame($this->model, $translation->model()); + $this->assertSame($version, $translation->version()); + $this->assertSame($language, $translation->language()); + $this->assertSame($content, $translation->version()->content()->toArray()); + $this->assertTrue($translation->version()->exists()); + } + + /** + * @covers ::create + */ + public function testCreateWithSlug() + { + $this->setUpMultiLanguage(); + + $translation = Translation::create( + model: $this->model, + version: $this->model->version(), + language: Language::ensure('en'), + fields: [ + 'title' => 'Test' + ], + slug: 'foo' + ); + + $this->assertSame(['title' => 'Test', 'slug' => 'foo'], $translation->version()->content()->toArray()); + $this->assertSame('foo', $translation->slug()); + } + + /** + * @covers ::exists + */ + public function testExistsMultiLanguage() + { + $this->setUpMultiLanguage(); + + $translationEN = new Translation( + model: $this->model, + version: $this->model->version(), + language: Language::ensure('en') + ); + + $translationDE = new Translation( + model: $this->model, + version: $this->model->version(), + language: Language::ensure('de') + ); + + $this->assertFalse($translationEN->version()->exists()); + $this->assertFalse($translationDE->version()->exists()); + + $this->createContentMultiLanguage(); + + $this->assertTrue($translationEN->version()->exists()); + $this->assertTrue($translationDE->version()->exists()); + } + + /** + * @covers ::exists + */ + public function testExistsSingleLanguage() + { + $this->setUpSingleLanguage(); + + $translation = new Translation( + model: $this->model, + version: $this->model->version(), + language: Language::single() + ); + + $this->assertFalse($translation->version()->exists()); + + $this->createContentSingleLanguage(); + + $this->assertTrue($translation->version()->exists()); + } + + /** + * @covers ::isDefault + */ + public function testIsDefault() + { + $this->setUpMultiLanguage(); + + $en = new Translation( + model: $this->model, + version: $this->model->version(), + language: Language::ensure('en') + ); + + $de = new Translation( + model: $this->model, + version: $this->model->version(), + language: Language::ensure('de') + ); + + $this->assertTrue($en->language()->isDefault()); + $this->assertFalse($de->language()->isDefault()); + } + + /** + * @covers ::language + */ + public function testLanguage() + { + $this->setUpMultiLanguage(); + + $translationEN = new Translation( + model: $this->model, + version: $this->model->version(), + language: $languageEN = Language::ensure('en') + ); + + $translationDE = new Translation( + model: $this->model, + version: $this->model->version(), + language: $languageDE = Language::ensure('de') + ); + + $this->assertSame($languageEN, $translationEN->language()); + $this->assertSame($languageDE, $translationDE->language()); + } + + /** + * @covers ::model + */ + public function testModel() + { + $this->setUpMultiLanguage(); + + $translation = new Translation( + model: $this->model, + version: $this->model->version(), + language: Language::ensure('en') + ); + + $this->assertSame($this->model, $translation->model()); + } + + /** + * @covers ::parent + */ + public function testParent() + { + $this->setUpSingleLanguage(); + + $translation = new Translation( + model: $this->model, + version: $version = $this->model->version(), + language: Language::ensure('default') + ); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('`$translation->parent()` has been deprecated. Please use `$translation->model()` instead'); + + $translation->parent(); + } + + /** + * @covers ::slug + */ + public function testSlugExists() + { + $this->setUpMultiLanguage(); + + $translation = new Translation( + model: $this->model, + version: $this->model->version(), + language: Language::ensure('de') + ); + + Data::write($this->contentFile('de'), [ + 'title' => 'Test', + 'slug' => 'german-slug' + ]); + + $this->assertSame('german-slug', $translation->slug()); + } + + /** + * @covers ::slug + */ + public function testSlugNotExists() + { + $this->setUpMultiLanguage(); + + $translation = new Translation( + model: $this->model, + version: $this->model->version(), + language: Language::ensure('de') + ); + + Data::write($this->contentFile('de'), [ + 'title' => 'Test', + ]); + + $this->assertNull($translation->slug()); + } + + /** + * @covers ::update + */ + public function testUpdate() + { + $this->setUpSingleLanguage(); + + $translation = new Translation( + model: $this->model, + version: $version = $this->model->version(), + language: Language::ensure('default') + ); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('`$translation->update()` has been deprecated. Please use `$model->version()->update()` instead'); + + $translation->update(); + } + + /** + * @covers ::toArray + */ + public function testToArray() + { + $this->setUpSingleLanguage(); + + $translation = new Translation( + model: $this->model, + version: $this->model->version(), + language: Language::ensure('en') + ); + + $expected = [ + 'code' => 'en', + 'content' => $this->createContentSingleLanguage()['content'], + 'exists' => true, + 'slug' => null, + ]; + + $this->assertSame($expected, $translation->toArray()); + } + + /** + * @covers ::version + */ + public function testVersion() + { + $this->setUpMultiLanguage(); + + $translation = new Translation( + model: $this->model, + version: $version = $this->model->version(), + language: Language::ensure('en') + ); + + $this->assertSame($version, $translation->version()); + } +} diff --git a/tests/Content/TranslationsTest.php b/tests/Content/TranslationsTest.php new file mode 100644 index 0000000000..afa682fe8f --- /dev/null +++ b/tests/Content/TranslationsTest.php @@ -0,0 +1,162 @@ +setUpMultiLanguage(); + + $translations = Translations::create( + model: $this->model, + version: $this->model->version(), + translations: [ + [ + 'code' => 'en', + 'content' => [ + 'title' => 'Title English' + ] + ], + [ + 'code' => 'de', + 'content' => [ + 'title' => 'Title Deutsch' + ] + ] + ] + ); + + $this->assertCount(2, $translations); + $this->assertSame('en', $translations->first()->language()->code()); + $this->assertSame('de', $translations->last()->language()->code()); + } + + /** + * @covers ::create + */ + public function testCreateSingleLanguage() + { + $this->setUpSingleLanguage(); + + $translations = Translations::create( + model: $this->model, + version: $this->model->version(), + translations: [ + [ + 'code' => 'en', + 'content' => [ + 'title' => 'Title English' + ] + ], + // should be ignored because the matching language is not installed + [ + 'code' => 'de', + 'content' => [ + 'title' => 'Title Deutsch' + ] + ] + ] + ); + + $this->assertCount(1, $translations); + $this->assertSame('en', $translations->first()->language()->code()); + $this->assertTrue($translations->first()->language()->isSingle()); + } + + /** + * @covers ::findByKey + */ + public function testFindByKeyMultiLanguage() + { + $this->setUpMultiLanguage(); + + $translations = Translations::load( + model: $this->model, + version: $this->model->version() + ); + + $this->assertSame('en', $translations->findByKey('en')->language()->code()); + $this->assertSame('en', $translations->findByKey('default')->language()->code()); + $this->assertSame('en', $translations->findByKey('current')->language()->code()); + $this->assertSame('de', $translations->findByKey('de')->language()->code()); + } + + /** + * @covers ::findByKey + */ + public function testFindByKeySingleLanguage() + { + $this->setUpSingleLanguage(); + + $translations = Translations::load( + model: $this->model, + version: $this->model->version() + ); + + $this->assertSame('en', $translations->findByKey('en')->language()->code()); + $this->assertSame('en', $translations->findByKey('default')->language()->code()); + $this->assertSame('en', $translations->findByKey('current')->language()->code()); + } + + /** + * @covers ::findByKey + */ + public function testFindByKeyWithInvalidLanguage() + { + $this->setUpMultiLanguage(); + + $translations = Translations::load( + model: $this->model, + version: $this->model->version() + ); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Invalid language: fr'); + + $translations->findByKey('fr'); + } + + /** + * @covers ::load + */ + public function testLoadMultiLanguage() + { + $this->setUpMultiLanguage(); + + $translations = Translations::load( + model: $this->model, + version: $this->model->version() + ); + + $this->assertCount(2, $translations); + $this->assertSame('en', $translations->first()->language()->code()); + $this->assertSame('de', $translations->last()->language()->code()); + } + + /** + * @covers ::load + */ + public function testLoadSingleLanguage() + { + $this->setUpSingleLanguage(); + + $translations = Translations::load( + model: $this->model, + version: $this->model->version() + ); + + $this->assertCount(1, $translations); + $this->assertSame('en', $translations->first()->language()->code()); + $this->assertTrue($translations->first()->language()->isSingle()); + } +} diff --git a/tests/Content/VersionTest.php b/tests/Content/VersionTest.php index bab8618d83..48b62357af 100644 --- a/tests/Content/VersionTest.php +++ b/tests/Content/VersionTest.php @@ -13,66 +13,6 @@ class VersionTest extends TestCase { public const TMP = KIRBY_TMP_DIR . '/Content.Version'; - public function assertContentFileExists(string|null $language = null, VersionId|null $versionId = null) - { - $this->assertFileExists($this->contentFile($language, $versionId)); - } - - public function assertContentFileDoesNotExist(string|null $language = null, VersionId|null $versionId = null) - { - $this->assertFileDoesNotExist($this->contentFile($language, $versionId)); - } - - public function contentFile(string|null $language = null, VersionId|null $versionId = null): string - { - return - $this->model->root() . - // add the changes folder - ($versionId?->value() === 'changes' ? '/_changes/' : '/') . - // template - 'article' . - // language code - ($language === null ? '' : '.' . $language) . - '.txt'; - } - - public function createContentMultiLanguage(): array - { - Data::write($fileEN = $this->contentFile('en'), $contentEN = [ - 'title' => 'Title English', - 'subtitle' => 'Subtitle English' - ]); - - Data::write($fileDE = $this->contentFile('de'), $contentDE = [ - 'title' => 'Title Deutsch', - 'subtitle' => 'Subtitle Deutsch' - ]); - - return [ - 'en' => [ - 'content' => $contentEN, - 'file' => $fileEN, - ], - 'de' => [ - 'content' => $contentDE, - 'file' => $fileDE, - ] - ]; - } - - public function createContentSingleLanguage(): array - { - Data::write($file = $this->contentFile(), $content = [ - 'title' => 'Title', - 'subtitle' => 'Subtitle' - ]); - - return [ - 'content' => $content, - 'file' => $file - ]; - } - /** * @covers ::content */