diff --git a/composer.json b/composer.json index c20e3ee9370..9439f8962c6 100644 --- a/composer.json +++ b/composer.json @@ -96,6 +96,7 @@ "matthiasmullie/minify": "1.3.75", "monolog/monolog": "^3.9", "mpdf/mpdf": "v8.2.6", + "natlibfi/finna-xml": "1.6.0", "paytrail/paytrail-php-sdk": "2.7.5", "pear/archive_tar": "^1.4", "phing/phing": "3.1.0", diff --git a/composer.lock b/composer.lock index f90e527b78d..ca8ae1ea9bb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a87c640baf64e1091768c0d37659e4ea", + "content-hash": "74d81eb28a9e22c439933bf0d049d919", "packages": [ { "name": "ahand/mobileesp", @@ -7328,6 +7328,60 @@ ], "time": "2025-08-01T08:46:24+00:00" }, + { + "name": "natlibfi/finna-xml", + "version": "1.6.0", + "source": { + "type": "git", + "url": "https://github.com/NatLibFi/finna-xml.git", + "reference": "1213fce9d4690029d17bbfde9f71bdc50c357db4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/NatLibFi/finna-xml/zipball/1213fce9d4690029d17bbfde9f71bdc50c357db4", + "reference": "1213fce9d4690029d17bbfde9f71bdc50c357db4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.89.2", + "phing/phing": "3.1.1", + "phpstan/phpstan": "2.1.32", + "phpunit/php-code-coverage": "^11", + "phpunit/phpunit": "11.5.50", + "rector/rector": "2.2.7", + "squizlabs/php_codesniffer": "4.0.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "FinnaXml\\": "src/FinnaXml" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ere Maijala", + "email": "ere.maijala@helsinki.fi" + } + ], + "description": "Yet another XML parser, reader and writer for diverse records with and without namespaces.", + "keywords": [ + "dom", + "parser", + "xml" + ], + "support": { + "issues": "https://github.com/NatLibFi/finna-xml/issues", + "source": "https://github.com/NatLibFi/finna-xml/tree/v1.6.0" + }, + "time": "2026-02-03T13:15:21+00:00" + }, { "name": "nette/schema", "version": "v1.3.3", diff --git a/module/VuFind/src/VuFind/RecordDriver/Feature/LocaleSupportTrait.php b/module/VuFind/src/VuFind/RecordDriver/Feature/LocaleSupportTrait.php new file mode 100644 index 00000000000..88e16b63aaf --- /dev/null +++ b/module/VuFind/src/VuFind/RecordDriver/Feature/LocaleSupportTrait.php @@ -0,0 +1,97 @@ +. + * + * @category VuFind + * @package RecordDrivers + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:record_drivers Wiki + */ + +namespace VuFind\RecordDriver\Feature; + +/** + * Functions for locale-specific processing in record drivers. + * + * @category VuFind + * @package RecordDrivers + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:record_drivers Wiki + */ +trait LocaleSupportTrait +{ + /** + * Pick correct results from locale-specific results with fallback to all results. + * + * @param array $localeResults Result(s) keyed by locale + * @param array|string $allResults All results + * + * @return array|string + */ + protected function getLocaleSpecificResults(array $localeResults, array|string $allResults): array|string + { + if (null === $this->localeSettings) { + return $allResults; + } + $userLocale = $this->localeSettings->getUserLocale(); + if (null !== ($results = $this->getBestLocaleMatch($userLocale, $localeResults))) { + return $results; + } + // Check for match in default and fallback locales: + $locales = [$this->localeSettings->getDefaultLocale(), ...$this->localeSettings->getFallbackLocales()]; + foreach ($locales as $locale) { + if (null !== ($results = $this->getBestLocaleMatch($locale, $localeResults))) { + return $results; + } + } + // Could not find anything else, so return all: + return $allResults; + } + + /** + * Pick best match for a locale from the results. + * + * @param string $locale Locale + * @param array $localeResults Result(s) keyed by locale + * + * @return mixed + */ + protected function getBestLocaleMatch(string $locale, array $localeResults): mixed + { + [$language] = explode('-', $locale); + if ($results = $localeResults[$locale] ?? $localeResults[$language] ?? null) { + return $results; + } + + // Check for matching language in locale-specific results: + [$language] = explode('-', $locale); + foreach ($localeResults as $resultLocale => $results) { + [$resultLanguage] = explode('-', $resultLocale); + if ($resultLanguage === $language) { + return $results; + } + } + + return null; + } +} diff --git a/module/VuFind/src/VuFind/RecordDriver/Feature/XmlTrait.php b/module/VuFind/src/VuFind/RecordDriver/Feature/XmlTrait.php new file mode 100644 index 00000000000..2c2c18ef078 --- /dev/null +++ b/module/VuFind/src/VuFind/RecordDriver/Feature/XmlTrait.php @@ -0,0 +1,97 @@ +. + * + * @category VuFind + * @package RecordDrivers + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:record_drivers Wiki + */ + +namespace VuFind\RecordDriver\Feature; + +use FinnaXml\XmlDoc; + +/** + * Functions for reading XML records. + * + * Assumption: raw XML data can be found in $this->fields['fullrecord']. + * + * @category VuFind + * @package RecordDrivers + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:record_drivers Wiki + */ +trait XmlTrait +{ + /** + * The XML namespace. + * + * Note: this is a property instead of a constant to make use of it in strings cleaner. + * + * @var string + */ + protected string $xmlNs = 'http://www.w3.org/2000/xmlns/'; + + /** + * XML class to use. + * + * @var string + */ + protected string $xmlClass = \FinnaXml\XmlDoc::class; + + /** + * XML instance. Access only via getXmlReader() as this is initialized lazily. + * + * @var XmlDoc + */ + protected ?XmlDoc $lazyXmlReader = null; + + /** + * Get access to the XML object. + * + * @return XmlDoc + */ + public function getXmlReader(): XmlDoc + { + if (null === $this->lazyXmlReader) { + $this->lazyXmlReader = new $this->xmlClass(); + $this->lazyXmlReader->parse($this->fields['fullrecord']); + } + + return $this->lazyXmlReader; + } + + /** + * Get lang attribute from xml namespace with fallback to default namespace. + * + * @param array $node XmlDoc node + * + * @return ?string + */ + protected function getLangAttr(array $node): ?string + { + $xml = $this->getXmlReader(); + return $xml->attr($node, '{{$this->xmlNs}}lang') ?? $xml->attr($node, 'lang'); + } +} diff --git a/module/VuFind/src/VuFind/RecordDriver/PluginManager.php b/module/VuFind/src/VuFind/RecordDriver/PluginManager.php index e18fd93767b..665d98ee3bb 100644 --- a/module/VuFind/src/VuFind/RecordDriver/PluginManager.php +++ b/module/VuFind/src/VuFind/RecordDriver/PluginManager.php @@ -69,6 +69,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager 'solrmarc' => SolrMarc::class, 'solrmarcremote' => SolrMarcRemote::class, 'solroverdrive' => SolrOverdrive::class, + 'solrqdc' => SolrQdc::class, 'solrreserves' => SolrReserves::class, 'solrweb' => SolrWeb::class, 'summon' => Summon::class, @@ -109,6 +110,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager SolrMarc::class => SolrDefaultFactory::class, SolrMarcRemote::class => SolrDefaultFactory::class, SolrOverdrive::class => SolrOverdriveFactory::class, + SolrQdc::class => SolrDefaultFactory::class, SolrReserves::class => SolrDefaultWithoutSearchServiceFactory::class, SolrWeb::class => SolrWebFactory::class, Summon::class => SummonFactory::class, diff --git a/module/VuFind/src/VuFind/RecordDriver/SolrQdc.php b/module/VuFind/src/VuFind/RecordDriver/SolrQdc.php new file mode 100644 index 00000000000..233f8a2b8e7 --- /dev/null +++ b/module/VuFind/src/VuFind/RecordDriver/SolrQdc.php @@ -0,0 +1,122 @@ +. + * + * @category VuFind + * @package RecordDrivers + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:record_drivers Wiki + */ + +namespace VuFind\RecordDriver; + +use VuFind\I18n\Locale\LocaleSettingsAwareInterface; +use VuFind\I18n\Locale\LocaleSettingsAwareTrait; +use VuFind\RecordDriver\Feature\LocaleSupportTrait; +use VuFind\RecordDriver\Feature\XmlTrait; + +/** + * Model for "Qualified Dublin Core" (using the DCMI Metadata Terms) records in Solr. + * + * @category VuFind + * @package RecordDrivers + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:record_drivers Wiki + */ +class SolrQdc extends SolrDefault implements LocaleSettingsAwareInterface +{ + use LocaleSettingsAwareTrait; + use LocaleSupportTrait; + use XmlTrait; + + /** + * Dublin Core XML namespace + * + * Note: this is a property instead of a constant to make use of it in strings cleaner. + * + * @var string + */ + protected string $dcNs = 'http://purl.org/dc/elements/1.1/'; + + /** + * Dublin Core Terms vocabulary namespace + * + * Note: this is a property instead of a constant to make use of it in strings cleaner. + * + * @var string + */ + protected string $dcTermsNs = 'http://purl.org/dc/terms/'; + + /** + * Get the abstract notes. + * + * @return array + */ + public function getAbstractNotes(): array + { + $allAbstracts = []; + $localeAbstracts = []; + $xml = $this->getXmlReader(); + foreach ($this->getDcTermsElements('abstract') as $node) { + $abstract = $xml->value($node); + if ($lang = $this->getLangAttr($node)) { + $localeAbstracts[$lang][] = $abstract; + } + $allAbstracts[] = $abstract; + } + + return $this->getLocaleSpecificResults($localeAbstracts, $allAbstracts); + } + + /** + * Get elements from the terms or elements namespaces with fallback to default namespace. + * + * @param string $nodeName Node name + * @param bool $valuesOnly Return only values? + * + * @return array + */ + protected function getElements(string $nodeName, bool $valuesOnly = false): array + { + $xml = $this->getXmlReader(); + // Prefer elements in the terms namespace: + $method = $valuesOnly ? 'allValues' : 'all'; + return $this->getDcTermsElements($nodeName, $valuesOnly) + ?: $xml->$method(path: "{{$this->dcNs}}$nodeName"); + } + + /** + * Get elements from the DcTerms namespace with fallback to default namespace. + * + * @param string $nodeName Node name + * @param bool $valuesOnly Return only values? + * + * @return array + */ + protected function getDcTermsElements(string $nodeName, bool $valuesOnly = false): array + { + $xml = $this->getXmlReader(); + $method = $valuesOnly ? 'allValues' : 'all'; + return $xml->$method(path: "{{$this->dcTermsNs}}$nodeName") ?: $xml->$method(path: $nodeName); + } +} diff --git a/module/VuFind/tests/fixtures/qdc/qdc.xml b/module/VuFind/tests/fixtures/qdc/qdc.xml new file mode 100644 index 00000000000..1fd029c83c3 --- /dev/null +++ b/module/VuFind/tests/fixtures/qdc/qdc.xml @@ -0,0 +1,7 @@ + + + Test + Abstrakti suomeksi. + Abstract in English. + Another abstract in English. + diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/RecordDriver/SolrQdcTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/RecordDriver/SolrQdcTest.php new file mode 100644 index 00000000000..d664e53f2cf --- /dev/null +++ b/module/VuFind/tests/unit-tests/src/VuFindTest/RecordDriver/SolrQdcTest.php @@ -0,0 +1,182 @@ +. + * + * @category VuFind + * @package Tests + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ + +namespace VuFindTest\RecordDriver; + +use PHPUnit\Framework\Attributes\DataProvider; +use VuFind\I18n\Locale\LocaleSettings; +use VuFind\RecordDriver\SolrQdc; + +/** + * SolrQdc Record Driver Test Class + * + * @category VuFind + * @package Tests + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +class SolrQdcTest extends \PHPUnit\Framework\TestCase +{ + use \VuFindTest\Feature\FixtureTrait; + + /** + * Data provider for testMethods. + * + * @return \Iterator + */ + public static function methodsProvider(): \Iterator + { + yield 'en' => [ + 'getAbstractNotes', + 'en', + 'en', + [], + [ + 'Abstract in English.', + 'Another abstract in English.', + ], + ]; + + yield 'fi' => [ + 'getAbstractNotes', + 'fi', + 'fi', + [], + [ + 'Abstrakti suomeksi.', + ], + ]; + + yield 'sv with en-gb as fallback' => [ + 'getAbstractNotes', + 'sv', + 'sv', + ['en-gb', 'fi'], + [ + 'Abstract in English.', + 'Another abstract in English.', + ], + ]; + + yield 'sv with en as default' => [ + 'getAbstractNotes', + 'sv', + 'en', + ['fi'], + [ + 'Abstract in English.', + 'Another abstract in English.', + ], + ]; + + yield 'sv with fi as default' => [ + 'getAbstractNotes', + 'sv', + 'fi', + ['en'], + [ + 'Abstrakti suomeksi.', + ], + ]; + + yield 'no fallback' => [ + 'getAbstractNotes', + 'sv', + 'sv', + [], + [ + 'Abstrakti suomeksi.', + 'Abstract in English.', + 'Another abstract in English.', + ], + ]; + } + + /** + * Test driver methods that return locale-specific data. + * + * @param string $method Method + * @param string $language Language + * @param string $defaultLanguage Default language + * @param array $fallbackLanguages Fallback languages + * @param mixed $expected Expected result + * + * @return void + */ + #[DataProvider('methodsProvider')] + public function testLocaleSpecificMethods( + string $method, + string $language, + string $defaultLanguage, + array $fallbackLanguages, + mixed $expected + ): void { + $driver = $this->getDriver($language, $defaultLanguage, $fallbackLanguages); + $this->assertSame( + $expected, + $driver->$method() + ); + } + + /** + * Get a record driver. + * + * @param string $language Language + * @param string $defaultLanguage Default language + * @param array $fallbackLanguages Fallback languages + * @param ?string $fixture Metadata fixture + * + * @return SolrQdc + */ + protected function getDriver( + string $language, + string $defaultLanguage, + array $fallbackLanguages, + ?string $fixture = 'qdc/qdc.xml' + ): SolrQdc { + $fixture = $this->getFixture($fixture); + $record = new SolrQdc(); + $record->setRawData(['id' => '12345', 'fullrecord' => $fixture]); + + $localeSettings = $this->createMock(LocaleSettings::class); + $localeSettings + ->method('getUserLocale') + ->willReturn($language); + $localeSettings + ->method('getDefaultLocale') + ->willReturn($defaultLanguage); + $localeSettings + ->method('getFallbackLocales') + ->willReturn($fallbackLanguages); + $record->setLocaleSettings($localeSettings); + + return $record; + } +}