diff --git a/Classes/Command/ImportCommand.php b/Classes/Command/ImportCommand.php index 976374c..358721e 100644 --- a/Classes/Command/ImportCommand.php +++ b/Classes/Command/ImportCommand.php @@ -63,6 +63,12 @@ protected function configure(): void InputArgument::REQUIRED, 'Storage pid of imported jobs', ); + $this->addOption( + 'language', + 'l', + InputOption::VALUE_REQUIRED, + 'Job language, should be the two-letter ISO 639-1 code of a configured site language', + ); $this->addOption( 'force', 'f', @@ -96,8 +102,10 @@ protected function initialize(InputInterface $input, OutputInterface $output): v protected function execute(InputInterface $input, OutputInterface $output): int { - /* @phpstan-ignore-next-line cast.int */ + /* @phpstan-ignore cast.int */ $storagePid = max(0, (int)$input->getArgument('storage-pid')); + /** @var string|null $language */ + $language = $input->getOption('language'); $force = (bool)$input->getOption('force'); $noDelete = (bool)$input->getOption('no-delete'); $noUpdate = (bool)$input->getOption('no-update'); @@ -111,7 +119,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // Fetch and import jobs from Personio API - $result = $this->personioImportService->import($storagePid, !$noUpdate, !$noDelete, $force, $dryRun); + $result = $this->personioImportService->import($storagePid, $language, !$noUpdate, !$noDelete, $force, $dryRun); // Show result $this->printResult($result); diff --git a/Classes/Domain/Factory/SchemaFactory.php b/Classes/Domain/Factory/SchemaFactory.php index 126fb9e..3e72fe9 100644 --- a/Classes/Domain/Factory/SchemaFactory.php +++ b/Classes/Domain/Factory/SchemaFactory.php @@ -156,7 +156,7 @@ private function decorateDescription(Job $job): string // https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/12.0/Breaking-96520-EnforceNon-emptyConfigurationInCObjparseFunc.html $parsedDescription = $this->contentObjectRenderer->parseFunc($description, null, '< lib.parseFunc_RTE'); } else { - /* @phpstan-ignore-next-line argument.type (Only relevant for legacy TYPO3 versions) */ + /* @phpstan-ignore argument.type (Only relevant for legacy TYPO3 versions) */ $parsedDescription = $this->contentObjectRenderer->parseFunc($description, [], '< lib.parseFunc_RTE'); } diff --git a/Classes/Domain/Model/Job.php b/Classes/Domain/Model/Job.php index 4d7856c..6e296b9 100644 --- a/Classes/Domain/Model/Job.php +++ b/Classes/Domain/Model/Job.php @@ -341,6 +341,16 @@ public function recalculateContentHash(): self return $this; } + /** + * @param int<-1, max> $languageId + */ + public function setLanguageId(int $languageId): self + { + $this->_languageUid = $languageId; + + return $this; + } + /** * @return array */ diff --git a/Classes/Domain/Repository/JobRepository.php b/Classes/Domain/Repository/JobRepository.php index 32a4a93..be0a912 100644 --- a/Classes/Domain/Repository/JobRepository.php +++ b/Classes/Domain/Repository/JobRepository.php @@ -25,6 +25,9 @@ use CPSIT\Typo3PersonioJobs\Domain\Model\Dto\Demand; use CPSIT\Typo3PersonioJobs\Domain\Model\Job; +use TYPO3\CMS\Core\Context\LanguageAspect; +use TYPO3\CMS\Core\Information\Typo3Version; +use TYPO3\CMS\Extbase\Persistence\QueryInterface; use TYPO3\CMS\Extbase\Persistence\QueryResultInterface; use TYPO3\CMS\Extbase\Persistence\Repository; @@ -49,7 +52,10 @@ public function findByDemand(Demand $demand): QueryResultInterface return $query->execute(); } - public function findOneByPersonioId(int $personioId, int $storagePid = null): ?Job + /** + * @param int<-1, max>|null $languageId + */ + public function findOneByPersonioId(int $personioId, int $storagePid = null, int $languageId = null): ?Job { $query = $this->createQuery(); @@ -60,6 +66,10 @@ public function findOneByPersonioId(int $personioId, int $storagePid = null): ?J $query->matching($query->equals('personioId', $personioId)); $query->setLimit(1); + if ($languageId !== null) { + $this->setLanguageForQuery($query, $languageId); + } + return $query->execute()->getFirst(); } @@ -74,9 +84,10 @@ public function findOneByJobDescription(int $jobDescription): ?Job /** * @param list $existingJobs + * @param int<-1, max>|null $languageId * @return QueryResultInterface */ - public function findOrphans(array $existingJobs, int $storagePid = null): QueryResultInterface + public function findOrphans(array $existingJobs, int $storagePid = null, int $languageId = null): QueryResultInterface { $query = $this->createQuery(); @@ -84,6 +95,10 @@ public function findOrphans(array $existingJobs, int $storagePid = null): QueryR $query->getQuerySettings()->setStoragePageIds([$storagePid]); } + if ($languageId !== null) { + $this->setLanguageForQuery($query, $languageId); + } + if ($existingJobs !== []) { $query->matching( $query->logicalNot( @@ -97,4 +112,22 @@ public function findOrphans(array $existingJobs, int $storagePid = null): QueryR return $query->execute(); } + + /** + * @param QueryInterface $query + * @param int<-1, max> $languageId + */ + protected function setLanguageForQuery(QueryInterface $query, int $languageId): void + { + $querySettings = $query->getQuerySettings(); + + // https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/12.0/Breaking-97926-ExtbaseQuerySettingsMethodsRemoved.html + if ((new Typo3Version())->getMajorVersion() >= 12) { + $languageAspect = new LanguageAspect($languageId, overlayType: LanguageAspect::OVERLAYS_MIXED); + $querySettings->setLanguageAspect($languageAspect); + } else { + /* @phpstan-ignore method.notFound */ + $querySettings->setLanguageUid($languageId); + } + } } diff --git a/Classes/Event/AfterJobsMappedEvent.php b/Classes/Event/AfterJobsMappedEvent.php index 9c8e501..3b1c685 100644 --- a/Classes/Event/AfterJobsMappedEvent.php +++ b/Classes/Event/AfterJobsMappedEvent.php @@ -40,6 +40,7 @@ final class AfterJobsMappedEvent public function __construct( private readonly Uri $requestUri, private readonly array $jobs, + private readonly ?string $language = null, ) {} public function getRequestUri(): Uri @@ -54,4 +55,9 @@ public function getJobs(): array { return $this->jobs; } + + public function getLanguage(): ?string + { + return $this->language; + } } diff --git a/Classes/Exception/UnavailableLanguageException.php b/Classes/Exception/UnavailableLanguageException.php new file mode 100644 index 0000000..8fae41f --- /dev/null +++ b/Classes/Exception/UnavailableLanguageException.php @@ -0,0 +1,41 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace CPSIT\Typo3PersonioJobs\Exception; + +/** + * UnavailableLanguageException + * + * @author Elias Häußler + * @license GPL-3.0-or-later + */ +final class UnavailableLanguageException extends \Exception +{ + public static function create(string $language): self + { + return new self( + sprintf('The given language "%s" is not available on this site.', $language), + 1704297254, + ); + } +} diff --git a/Classes/Service/PersonioApiService.php b/Classes/Service/PersonioApiService.php index 8c89831..43c7397 100644 --- a/Classes/Service/PersonioApiService.php +++ b/Classes/Service/PersonioApiService.php @@ -69,9 +69,14 @@ public function __construct( * @throws ArrayPathIsInvalid * @throws XmlIsMalformed */ - public function getJobs(): array + public function getJobs(string $language = null): array { $requestUri = $this->apiUrl->withPath('/xml'); + + if ($language !== null) { + $requestUri = $requestUri->withQuery('language=' . $language); + } + $response = $this->requestFactory->request((string)$requestUri); $source = XmlSource::fromXmlString((string)$response->getBody()) ->asCollection('position') @@ -81,7 +86,7 @@ public function getJobs(): array try { $jobs = $this->mapper->map('list<' . Job::class . '>', $source['position']); - $this->eventDispatcher->dispatch(new AfterJobsMappedEvent($requestUri, $jobs)); + $this->eventDispatcher->dispatch(new AfterJobsMappedEvent($requestUri, $jobs, $language)); return $jobs; } catch (MappingError $error) { diff --git a/Classes/Service/PersonioImportService.php b/Classes/Service/PersonioImportService.php index dc6a208..2a446d4 100644 --- a/Classes/Service/PersonioImportService.php +++ b/Classes/Service/PersonioImportService.php @@ -30,12 +30,15 @@ use CPSIT\Typo3PersonioJobs\Enums\ImportOperation; use CPSIT\Typo3PersonioJobs\Event\AfterJobsImportedEvent; use CPSIT\Typo3PersonioJobs\Exception\InvalidParametersException; +use CPSIT\Typo3PersonioJobs\Exception\UnavailableLanguageException; use CPSIT\Typo3PersonioJobs\Helper\SlugHelper; use EliasHaeussler\ValinorXml\Exception\ArrayPathHasUnexpectedType; use EliasHaeussler\ValinorXml\Exception\ArrayPathIsInvalid; use EliasHaeussler\ValinorXml\Exception\XmlIsMalformed; use Psr\EventDispatcher\EventDispatcherInterface; use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Exception\SiteNotFoundException; +use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface; /** @@ -55,6 +58,7 @@ public function __construct( private readonly JobRepository $jobRepository, private readonly PersistenceManagerInterface $persistenceManager, private readonly PersonioApiService $personioApiService, + private readonly SiteFinder $siteFinder, ) { $this->result = new ImportResult(false); } @@ -64,10 +68,12 @@ public function __construct( * @throws ArrayPathHasUnexpectedType * @throws ArrayPathIsInvalid * @throws InvalidParametersException + * @throws UnavailableLanguageException * @throws XmlIsMalformed */ public function import( int $storagePid, + string $language = null, bool $updateExistingJobs = true, bool $deleteOrphans = true, bool $forceImport = false, @@ -80,9 +86,16 @@ public function import( throw InvalidParametersException::create('$updateExistingJobs', '$forceImport'); } + // Resolve language id + if ($language !== null) { + $languageId = $this->resolveLanguageId($language, $storagePid) ?? throw UnavailableLanguageException::create($language); + } else { + $languageId = null; + } + // Fetch jobs from Personio API - $jobs = $this->personioApiService->getJobs(); - $orphans = $deleteOrphans ? $this->jobRepository->findOrphans($jobs, $storagePid) : []; + $jobs = $this->personioApiService->getJobs($language); + $orphans = $deleteOrphans ? $this->jobRepository->findOrphans($jobs, $storagePid, $languageId) : []; // Process imported jobs foreach ($jobs as $job) { @@ -92,7 +105,7 @@ public function import( $jobDescription->setPid($storagePid); } - $this->addOrUpdateJob($job, $storagePid, $forceImport, $updateExistingJobs); + $this->addOrUpdateJob($job, $storagePid, $languageId, $forceImport, $updateExistingJobs); } // Remove orphaned jobs @@ -106,9 +119,19 @@ public function import( return $this->result; } - private function addOrUpdateJob(Job $job, int $storagePid, bool $force = false, bool $update = true): void - { - $existingJob = $this->jobRepository->findOneByPersonioId($job->getPersonioId(), $storagePid); + /** + * @param int<-1, max>|null $languageId + */ + private function addOrUpdateJob( + Job $job, + int $storagePid, + int $languageId = null, + bool $force = false, + bool $update = true, + ): void { + $job->setLanguageId($languageId ?? -1); + + $existingJob = $this->jobRepository->findOneByPersonioId($job->getPersonioId(), $storagePid, $languageId); // Add non-existing job if ($existingJob === null) { @@ -241,4 +264,27 @@ private function getModifiedJobs(): \Generator yield $newJob; } } + + /** + * @phpstan-return non-negative-int + */ + private function resolveLanguageId(string $language, int $storagePid): ?int + { + try { + $site = $this->siteFinder->getSiteByPageId($storagePid); + } catch (SiteNotFoundException) { + return null; + } + + foreach ($site->getLanguages() as $siteLanguage) { + if ($siteLanguage->getTwoLetterIsoCode() === $language) { + /** @var non-negative-int $languageId */ + $languageId = $siteLanguage->getLanguageId(); + + return $languageId; + } + } + + return null; + } } diff --git a/Configuration/TCA/tx_personiojobs_domain_model_job.php b/Configuration/TCA/tx_personiojobs_domain_model_job.php index 07e0df2..b624056 100644 --- a/Configuration/TCA/tx_personiojobs_domain_model_job.php +++ b/Configuration/TCA/tx_personiojobs_domain_model_job.php @@ -39,6 +39,10 @@ 'tstamp' => 'tstamp', 'crdate' => 'crdate', 'title' => 'LLL:EXT:personio_jobs/Resources/Private/Language/locallang_db.xlf:tx_personiojobs_domain_model_job', + 'languageField' => 'sys_language_uid', + 'transOrigPointerField' => 'l10n_parent', + 'transOrigDiffSourceField' => 'l10n_diffsource', + 'translationSource' => 'l10n_source', 'delete' => 'deleted', 'sortby' => 'sorting', 'enablecolumns' => [ @@ -108,6 +112,36 @@ 'l10n_mode' => 'exclude', 'l10n_display' => 'defaultAsReadonly', ], + 'sys_language_uid' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', + 'config' => [ + 'type' => 'language', + ], + ], + 'l10n_parent' => [ + 'displayCond' => 'FIELD:sys_language_uid:>:0', + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', + 'config' => [ + 'type' => 'group', + 'allowed' => \CPSIT\Typo3PersonioJobs\Domain\Model\Job::TABLE_NAME, + 'size' => 1, + 'maxitems' => 1, + 'minitems' => 0, + 'default' => 0, + ], + ], + 'l10n_source' => [ + 'config' => [ + 'type' => 'passthrough', + ], + ], + 'l10n_diffsource' => [ + 'config' => [ + 'type' => 'passthrough', + 'default' => '', + ], + ], 'personio_id' => [ 'exclude' => true, 'label' => 'LLL:EXT:personio_jobs/Resources/Private/Language/locallang_db.xlf:tx_personiojobs_domain_model_job.personio_id', @@ -336,10 +370,14 @@ create_date, --div--;LLL:EXT:personio_jobs/Resources/Private/Language/locallang_db.xlf:tabs.description, job_descriptions, + --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language, + sys_language_uid, + l10n_parent, + l10n_diffsource, --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, - endtime + endtime, ', ], ], diff --git a/README.md b/README.md index 2668802..1d344cf 100644 --- a/README.md +++ b/README.md @@ -82,13 +82,19 @@ typo3 personio-jobs:import [options] The following command parameters are available: -| Command parameter | Description | Required | Default | -|-------------------------|----------------------------------------------------------|----------|---------| -| **`storage-pid`** | Storage pid of imported jobs | ✅ | – | -| **`-f`**, **`--force`** | Enforce re-import of unchanged jobs | – | no | -| **`--no-delete`** | Do not delete orphaned jobs | – | no | -| **`--no-update`** | Do not update imported jobs that have been changed | – | no | -| **`--dry-run`** | Do not perform database operations, only display changes | – | no | +| Command parameter | Description | Required | Default | +|----------------------------|-------------------------------------------------------------------------------------|----------|----------------| +| **`storage-pid`** | Storage pid of imported jobs | ✅ | – | +| **`-l`**, **`--language`** | Job language, should be the two-letter ISO 639-1 code of a configured site language | – | –1) | +| **`-f`**, **`--force`** | Enforce re-import of unchanged jobs | – | no | +| **`--no-delete`** | Do not delete orphaned jobs | – | no | +| **`--no-update`** | Do not update imported jobs that have been changed | – | no | +| **`--dry-run`** | Do not perform database operations, only display changes | – | no | + +*1) If no language is configured, Personio will return +jobs from the default language. They will be persisted in a way that +all languages are matched (that is, the value of `sys_language_uid` +will be `-1`).* 💡 Increase verbosity with `--verbose` or `-v` to show all changes, even unchanged jobs that were skipped. diff --git a/Tests/Unit/Exception/UnavailableLanguageExceptionTest.php b/Tests/Unit/Exception/UnavailableLanguageExceptionTest.php new file mode 100644 index 0000000..5552d23 --- /dev/null +++ b/Tests/Unit/Exception/UnavailableLanguageExceptionTest.php @@ -0,0 +1,48 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace CPSIT\Typo3PersonioJobs\Tests\Unit\Exception; + +use CPSIT\Typo3PersonioJobs\Exception\UnavailableLanguageException; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +/** + * UnavailableLanguageExceptionTest + * + * @author Elias Häußler + * @license GPL-2.0-or-later + * @covers \CPSIT\Typo3PersonioJobs\Exception\UnavailableLanguageException + */ +final class UnavailableLanguageExceptionTest extends UnitTestCase +{ + /** + * @test + */ + public function createReturnsMalformedXmlException(): void + { + $actual = UnavailableLanguageException::create('foo'); + + self::assertSame('The given language "foo" is not available on this site.', $actual->getMessage()); + self::assertSame(1704297254, $actual->getCode()); + } +} diff --git a/Tests/Unit/Fixtures/Classes/DummyRequestFactory.php b/Tests/Unit/Fixtures/Classes/DummyRequestFactory.php index 92ae38d..bc4fb65 100644 --- a/Tests/Unit/Fixtures/Classes/DummyRequestFactory.php +++ b/Tests/Unit/Fixtures/Classes/DummyRequestFactory.php @@ -36,6 +36,8 @@ */ final class DummyRequestFactory extends RequestFactory { + public ?string $lastUri = null; + public function __construct( public ResponseInterface $response = new Response(), public ?\Throwable $exception = null, @@ -48,6 +50,8 @@ public function __construct( */ public function request(string $uri, string $method = 'GET', array $options = []): ResponseInterface { + $this->lastUri = $uri; + if ($this->exception !== null) { throw $this->exception; } diff --git a/Tests/Unit/Fixtures/Files/api-response-other-language.xml b/Tests/Unit/Fixtures/Files/api-response-other-language.xml new file mode 100644 index 0000000..f00bcfb --- /dev/null +++ b/Tests/Unit/Fixtures/Files/api-response-other-language.xml @@ -0,0 +1,63 @@ + + + + 1 + Testfirma + Berlin + IT + Testing + Software-Tester (w/m/d) + + + Hello World! + + Lorem ipsum dolor sit amet.]]> + + + + See you soon! + + Lorem ipsum dolor sit amet.]]> + + + + permanent + experienced + full-time + 2-5 + Testing,QS + software_and_web_development + it_software + 2023-08-11T14:15:17+00:00 + + + 2 + Testfirma + Berlin + IT + Testing + Software-Tester (w/m/d) + + + Hello World! + + Lorem ipsum dolor sit amet.]]> + + + + See you soon! + + Lorem ipsum dolor sit amet.]]> + + + + permanent + experienced + full-time + 2-5 + Testing,QS + software_and_web_development + it_software + 2023-08-11T14:15:17+00:00 + + diff --git a/Tests/Unit/Service/PersonioApiServiceTest.php b/Tests/Unit/Service/PersonioApiServiceTest.php index 34bba02..45e85ce 100644 --- a/Tests/Unit/Service/PersonioApiServiceTest.php +++ b/Tests/Unit/Service/PersonioApiServiceTest.php @@ -147,6 +147,23 @@ public function getJobsReturnsMappedJobObjectWithoutWorkingExperience(): void self::assertJobEqualsJob($this->createJob(1, null), $actual[0]); } + /** + * @test + */ + public function getJobsReturnsJobInGivenLanguage(): void + { + $stream = $this->streamFactory->createStreamFromFile(dirname(__DIR__) . '/Fixtures/Files/api-response-other-language.xml'); + + $this->requestFactory->response = new Response($stream); + + $actual = $this->subject->getJobs('de'); + + self::assertSame('https://testing.jobs.personio.local/xml?language=de', $this->requestFactory->lastUri); + self::assertCount(2, $actual); + self::assertJobEqualsJob($this->createGermanJob(1), $actual[0]); + self::assertJobEqualsJob($this->createGermanJob(2), $actual[1]); + } + private static function assertJobEqualsJob(Job $expected, Job $actual): void { // Create expected job descriptions @@ -203,4 +220,15 @@ private function createJob(int $id, ?YearsOfExperience $yearsOfExperience = Year return $job; } + + private function createGermanJob(int $id): Job + { + $job = $this->createJob($id) + ->setSubcompany('Testfirma') + ->setName('Software-Tester (w/m/d)') + ->setKeywords('Testing,QS'); + $job->recalculateContentHash(); + + return $job; + } }