diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..0bc613d
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,135 @@
+name: Tests
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+env:
+ PACKAGE_FOLDER: metadata
+
+jobs:
+ codestyle:
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php-versions: ['8.4']
+
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+
+ - name: Cache dependencies
+ uses: actions/cache@v5
+ with:
+ path: ~/.composer/cache
+ key: dependencies-composer-${{ hashFiles('composer.json') }}
+
+ - name: Install dependencies
+ uses: php-actions/composer@v6
+ with:
+ php_version: ${{ matrix.php-versions }}
+ version: 2
+
+ - name: PHPStan
+ uses: php-actions/phpstan@v3
+ with:
+ php_version: ${{ matrix.php-versions }}
+ configuration: phpstan.ci.neon
+
+ php-unit-tests:
+ env:
+ FLOW_CONTEXT: Testing
+ FLOW_FOLDER: ../flow-base-distribution
+
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php-versions: ['8.4']
+ flow-versions: ['8.4']
+
+ services:
+ mariadb:
+ image: mariadb:11
+ env:
+ MARIADB_DATABASE: neos
+ MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 'yes'
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mariadb-admin ping --silent"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=10
+
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Set package branch name
+ run: echo "PACKAGE_TARGET_VERSION=${GITHUB_BASE_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_ENV
+ working-directory: .
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+ extensions: mbstring, xml, json, zlib, iconv, intl, pdo_sqlite, mysql
+ coverage: xdebug #optional
+ ini-values: opcache.fast_shutdown=0
+
+ - name: Cache dependencies
+ uses: actions/cache@v5
+ with:
+ path: ~/.composer/cache
+ key: dependencies-composer-${{ hashFiles('composer.json') }}
+
+ - name: Prepare Flow distribution
+ run: |
+ git clone https://github.com/neos/flow-base-distribution.git -b ${{ matrix.flow-versions }} ${FLOW_FOLDER}
+ cd ${FLOW_FOLDER}
+ composer require --no-update --dev --no-interaction phpunit/phpunit:"^11.0"
+
+ git -C ../${{ env.PACKAGE_FOLDER }} checkout -b build
+ composer config repositories.package '{ "type": "path", "url": "../${{ env.PACKAGE_FOLDER }}", "options": { "symlink": false } }'
+ composer require --no-update --no-interaction neos/metadata:"dev-build as dev-${PACKAGE_TARGET_VERSION}"
+
+ # The storage tests target MySQL/MariaDB, so the Testing context uses the CI database
+ # service instead of the default in-memory sqlite backend.
+ mkdir -p Configuration/Testing
+ cat > Configuration/Testing/Settings.yaml <<'EOF'
+ Neos:
+ Flow:
+ persistence:
+ backendOptions:
+ driver: 'pdo_mysql'
+ host: '127.0.0.1'
+ dbname: 'neos'
+ user: 'root'
+ password: ''
+ EOF
+
+ - name: Composer Install
+ run: |
+ cd ${FLOW_FOLDER}
+ composer install --no-interaction --no-progress
+
+ - name: Run Unit tests
+ run: |
+ cd ${FLOW_FOLDER}
+ bin/phpunit --colors -c Build/BuildEssentials/PhpUnit/UnitTests.xml Packages/Application/Neos.MetaData/Tests/Unit/
+
+ - name: Run Functional tests
+ # The storage tests target MySQL/MariaDB and run against the mariadb service container of
+ # this job. They would be skipped on the default sqlite backend.
+ run: |
+ cd ${FLOW_FOLDER}
+ bin/phpunit --colors -c Build/BuildEssentials/PhpUnit/FunctionalTests.xml Packages/Application/Neos.MetaData/Tests/Functional/
\ No newline at end of file
diff --git a/Classes/Command/AssetMetaDataCommandController.php b/Classes/Command/AssetMetaDataCommandController.php
new file mode 100644
index 0000000..1b23019
--- /dev/null
+++ b/Classes/Command/AssetMetaDataCommandController.php
@@ -0,0 +1,235 @@
+metaDataManager->setMetaDataPropertyValue(
+ $assetReference,
+ MetaDataPropertyName::fromString($property),
+ $value,
+ $dimensionSpacePointDecoded,
+ );
+ $message = sprintf('Metadata property "%s" of asset "%s" was set to "%s"', $property, $assetId, $value);
+ if ($dimensionSpacePointDecoded !== null) {
+ $message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded);
+ }
+ $this->outputLine("$message");
+ }
+
+ /**
+ * Removes a metadata property for an asset
+ *
+ * @param string $assetId ID of the asset to unset the metadata property for
+ * @param string $property name of the metadata property to unset
+ * @param string|null $assetSource optional asset source - default = "neos"
+ * @param string|null $dimensionSpacePoint optional dimension space point as JSON (e.g. `'{"language": "de"}') - default = the configured defaultDimensionSpacePoint
+ */
+ public function unsetCommand(string $assetId, string $property, string|null $assetSource = null, string|null $dimensionSpacePoint = null): void
+ {
+ $dimensionSpacePointDecoded = $dimensionSpacePoint !== null ? self::parseDimensionSpacePoint($dimensionSpacePoint) : null;
+ $assetReference = MetaDataAssetReference::create($assetSource ?? 'neos', $assetId);
+ $this->metaDataManager->unsetMetaDataPropertyValue(
+ $assetReference,
+ MetaDataPropertyName::fromString($property),
+ $dimensionSpacePointDecoded,
+ );
+ $message = sprintf('Metadata property "%s" of asset "%s" was unset', $property, $assetId);
+ if ($dimensionSpacePointDecoded !== null) {
+ $message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded);
+ }
+ $this->outputLine("$message");
+ }
+
+ /**
+ * Lists all metadata properties for an asset
+ *
+ * Values that stem from a fallback dimension are marked as inherited.
+ *
+ * @param string $assetId ID of the asset to list the metadata properties for
+ * @param string|null $assetSource optional asset source - default = "neos"
+ * @param string|null $dimensionSpacePoint optional dimension space point as JSON (e.g. `'{"language": "de"}') - default = the configured defaultDimensionSpacePoint
+ */
+ public function listCommand(string $assetId, string|null $assetSource = null, string|null $dimensionSpacePoint = null): void
+ {
+ $dimensionSpacePointDecoded = $dimensionSpacePoint !== null ? self::parseDimensionSpacePoint($dimensionSpacePoint) : null;
+ $assetReference = MetaDataAssetReference::create($assetSource ?? 'neos', $assetId);
+ $metaDataPropertyValues = $this->metaDataManager->getMetaDataPropertyValues(
+ $assetReference,
+ $dimensionSpacePointDecoded,
+ );
+ $message = sprintf('Metadata properties of asset "%s"', $assetId);
+ if ($dimensionSpacePointDecoded !== null) {
+ $message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded);
+ }
+ $this->outputLine($message . ':');
+ foreach ($metaDataPropertyValues as $propertyName => $propertyValue) {
+ $line = sprintf(' %s: %s', $propertyName, $propertyValue->value ?? '-');
+ if ($propertyValue->isInherited()) {
+ $line .= sprintf(' (inherited from %s)', $propertyValue->inheritedFrom);
+ }
+ $this->outputLine($line);
+ }
+ }
+
+ /**
+ * Finds and fixes metadata values whose scope contradicts the current configuration
+ *
+ * Whether a property has a single shared value or one value per dimension is configured via
+ * `Neos.MetaData.metaDataProperties..globalScope`. Changing that leaves values behind that no
+ * longer match. Those are never returned when reading metadata, so this command is about tidying up
+ * rather than about fixing broken reads.
+ *
+ * Without `--force` nothing is changed and the pending changes are merely reported.
+ *
+ * @param bool $force apply the changes instead of only reporting them
+ * @param bool $prune also remove values of dimensions and of properties that are no longer configured
+ */
+ public function repairCommand(bool $force = false, bool $prune = false): void
+ {
+ if (!$this->metaDataRepair->isSupported()) {
+ $this->outputLine('The configured metadata storage does not support repairing');
+ $this->quit(1);
+ }
+ if ($prune && !$this->metaDataRepair->hasConfiguredDimensions()) {
+ $this->outputLine('Refusing to prune because no content dimension is configured');
+ $this->outputLine('Every value stored for a dimension would look obsolete, which is also what a broken dimension configuration looks like.');
+ $this->quit(1);
+ }
+
+ $actions = $this->metaDataRepair->analyze();
+ if ($actions === []) {
+ $this->outputLine('No metadata values need repairing');
+ return;
+ }
+
+ $this->outputScopeActions($actions);
+ $this->outputPruneActions($actions, $prune);
+
+ $applicable = array_filter($actions, static fn (MetaDataRepairAction $action) => $prune || !$action->type->requiresPrune());
+ if ($applicable === []) {
+ return;
+ }
+ if (!$force) {
+ $this->outputLine();
+ $this->outputLine('Nothing was changed. Re-run with --force to apply.');
+ return;
+ }
+ $deleted = $this->metaDataRepair->apply($actions, $prune);
+ $this->outputLine();
+ $this->outputLine('Repaired metadata values, %d value(s) were removed', [$deleted]);
+ }
+
+ // -----------------------
+
+ /**
+ * @param list $actions
+ */
+ private function outputScopeActions(array $actions): void
+ {
+ $scopeActions = array_filter($actions, static fn (MetaDataRepairAction $action) => !$action->type->requiresPrune());
+ if ($scopeActions === []) {
+ return;
+ }
+ $this->outputLine('Values with a scope that contradicts the property definition:');
+ foreach ($scopeActions as $action) {
+ $storedValue = $action->storedValue;
+ $description = match ($action->type) {
+ MetaDataRepairActionType::promoteToGlobalScope => sprintf('keep "%s" as the shared value', $storedValue->value),
+ MetaDataRepairActionType::promoteToDefaultDimension => sprintf('store "%s" for the default dimension', $storedValue->value),
+ MetaDataRepairActionType::deleteWrongScope => sprintf('delete "%s" (%s)', $storedValue->value, $storedValue->global ? 'shared value' : 'dimension ' . $storedValue->dimensionHash),
+ default => '',
+ };
+ $this->outputLine(sprintf(' %s / %s: %s', $storedValue->assetReference->assetId, $storedValue->propertyName, $description));
+ }
+ }
+
+ /**
+ * @param list $actions
+ */
+ private function outputPruneActions(array $actions, bool $prune): void
+ {
+ $obsoleteDimensions = 0;
+ $undefinedProperties = 0;
+ foreach ($actions as $action) {
+ match ($action->type) {
+ MetaDataRepairActionType::deleteObsoleteDimension => $obsoleteDimensions++,
+ MetaDataRepairActionType::deleteUndefinedProperty => $undefinedProperties++,
+ default => null,
+ };
+ }
+ if ($obsoleteDimensions === 0 && $undefinedProperties === 0) {
+ return;
+ }
+ $this->outputLine();
+ $this->outputLine('Unreachable values:');
+ if ($obsoleteDimensions > 0) {
+ $this->outputLine(sprintf(' %d value(s) stored for a dimension that is no longer configured', $obsoleteDimensions));
+ }
+ if ($undefinedProperties > 0) {
+ $this->outputLine(sprintf(' %d value(s) of a property that is no longer defined', $undefinedProperties));
+ }
+ if (!$prune) {
+ $this->outputLine(' Re-run with --prune to include them.');
+ }
+ }
+
+ private static function parseDimensionSpacePoint(string $dimensionSpacePoint): MetaDataDimensionSpacePoint
+ {
+ try {
+ $coordinates = json_decode($dimensionSpacePoint, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ throw new InvalidArgumentException('Failed to parse dimension space point: ' . $e->getMessage(), 1776274597, $e);
+ }
+ if (!is_array($coordinates)) {
+ throw new InvalidArgumentException('Failed to parse dimension space point: expected a JSON object of coordinates', 1776274598);
+ }
+ $coordinateValues = [];
+ foreach ($coordinates as $dimensionName => $coordinateValue) {
+ if (!is_string($dimensionName) || !is_string($coordinateValue)) {
+ throw new InvalidArgumentException('Failed to parse dimension space point: coordinates must map dimension names to string values', 1776274599);
+ }
+ $coordinateValues[$dimensionName] = $coordinateValue;
+ }
+ return MetaDataDimensionSpacePoint::fromCoordinates($coordinateValues);
+ }
+
+}
diff --git a/Classes/Command/AssetMetaDataMigrationCommandController.php b/Classes/Command/AssetMetaDataMigrationCommandController.php
new file mode 100644
index 0000000..d68e407
--- /dev/null
+++ b/Classes/Command/AssetMetaDataMigrationCommandController.php
@@ -0,0 +1,47 @@
+assetRepository->findAll() as $asset) {
+ $caption = $asset->getCaption();
+ $copyrightNotice = $asset->getCopyrightNotice();
+ $metaDataAssetReference = MetaDataAssetReference::create($asset->assetSourceIdentifier, $asset->getIdentifier());
+
+ if (!empty($caption)) {
+ $this->metaDataManager->setMetaDataPropertyValue(
+ $metaDataAssetReference,
+ MetaDataPropertyName::fromString('caption'),
+ $caption,
+ );
+ }
+ if (!empty($copyrightNotice)) {
+ $this->metaDataManager->setMetaDataPropertyValue(
+ $metaDataAssetReference,
+ MetaDataPropertyName::fromString('copyright'),
+ $copyrightNotice,
+ );
+ }
+ }
+ }
+}
diff --git a/Classes/Configuration/MetaDataConfigurationProvider.php b/Classes/Configuration/MetaDataConfigurationProvider.php
new file mode 100644
index 0000000..2c906b1
--- /dev/null
+++ b/Classes/Configuration/MetaDataConfigurationProvider.php
@@ -0,0 +1,14 @@
+
+ * }
+ * }
+ * }|null> $propertyConfiguration
+ */
+ public function __construct(
+ private readonly array $propertyConfiguration,
+ private readonly Translator $translator,
+ )
+ {
+ }
+
+ public function getPropertyConfiguration(): MetaDataPropertyDefinitions
+ {
+ $propertyDefinitions = [];
+ foreach ($this->propertyConfiguration as $propertyName => $propertyDefinition) {
+ if ($propertyDefinition === null) {
+ // allows to disable property definitions that are configured elsewhere
+ continue;
+ }
+ $propertyDefinitions[] = new MetaDataPropertyDefinition(
+ MetaDataPropertyName::fromString($propertyName),
+ match ($propertyDefinition['type'] ?? null) {
+ 'integer' => MetaDataPropertyType::integer,
+ 'boolean' => MetaDataPropertyType::boolean,
+ default => MetaDataPropertyType::string,
+ },
+ $propertyDefinition['globalScope'] ?? false,
+ array_key_exists('ui', $propertyDefinition) ? new MetaDataPropertyUiDefinition(
+ $this->translatePropertyName($propertyName, $propertyDefinition['ui']['label'] ?? null),
+ MetaDataEditorDefinition::create(
+ editorType: $propertyDefinition['ui']['inspector']['editor'] ?? null,
+ options: $propertyDefinition['ui']['inspector']['editorOptions'] ?? [],
+ )
+ ) : null,
+ );
+ }
+ return MetaDataPropertyDefinitions::create(...$propertyDefinitions);
+ }
+
+ // -----------------------
+
+ private function translatePropertyName(string $propertyName, ?string $label): string
+ {
+ if ($label === 'i18n') {
+ $translationShortHandString = sprintf('properties.%s', $propertyName);
+ return $this->translator->translateById(
+ $translationShortHandString,
+ [],
+ null,
+ null,
+ 'Main',
+ 'Neos.MetaData'
+ ) ?? $propertyName;
+ }
+ if ($label !== null && preg_match(self::I18N_LABEL_ID_PATTERN, $label) === 1) {
+ // A translation shorthand string like 'PackageKey:Source:trans-unit-id'
+ [$packageKey, $sourceName, $id] = explode(':', $label);
+ return $this->translator->translateById(
+ $id,
+ [],
+ null,
+ null,
+ str_replace('.', '/', $sourceName),
+ $packageKey
+ ) ?? $propertyName;
+ }
+ return $label ?? $propertyName;
+ }
+}
diff --git a/Classes/Configuration/MetaDataConfigurationProviderYamlAdapterFactory.php b/Classes/Configuration/MetaDataConfigurationProviderYamlAdapterFactory.php
new file mode 100644
index 0000000..22cbf60
--- /dev/null
+++ b/Classes/Configuration/MetaDataConfigurationProviderYamlAdapterFactory.php
@@ -0,0 +1,34 @@
+
+ * }
+ * }
+ * }|null> $propertyConfiguration
+ */
+ public function __construct(
+ private readonly array $propertyConfiguration,
+ private readonly Translator $translator,
+ )
+ {
+ }
+
+ public function create(): MetaDataConfigurationProvider
+ {
+ return new MetaDataConfigurationProviderYamlAdapter($this->propertyConfiguration, $this->translator);
+ }
+}
diff --git a/Classes/DimensionSpacePointProvider/DimensionSpacePointProvider.php b/Classes/DimensionSpacePointProvider/DimensionSpacePointProvider.php
new file mode 100644
index 0000000..42ca71f
--- /dev/null
+++ b/Classes/DimensionSpacePointProvider/DimensionSpacePointProvider.php
@@ -0,0 +1,22 @@
+
+ * }>
+ * }>|null
+ */
+ private ?array $allPresets = null;
+
+ public function __construct(
+ private readonly ConfigurationContentDimensionPresetSource $configurationContentDimensionPresetSource,
+ ) {
+ }
+
+ public function getDimensionSpacePoints(): MetaDataDimensionSpacePoints
+ {
+ $presets = $this->getAllPresets();
+ return MetaDataDimensionSpacePoints::create(...array_map(
+ fn ($coords) => MetaDataDimensionSpacePoint::fromCoordinates($coords),
+ $this->createAllPresetCombinations($presets)
+ ));
+ }
+
+ /**
+ * @return array
+ * }>
+ * }>
+ */
+ private function getAllPresets(): array
+ {
+ if ($this->allPresets === null) {
+ $this->allPresets = $this->configurationContentDimensionPresetSource->getAllPresets();
+ }
+ return $this->allPresets;
+ }
+
+ public function getDefaultDimensionSpacePoint(): MetaDataDimensionSpacePoint
+ {
+ $presets = $this->getAllPresets();
+ $ccordinates = [];
+ foreach ($presets as $dimensionName => $dimensionConfig) {
+ $ccordinates[$dimensionName] = $dimensionConfig['default'] ?? '';
+ }
+ return MetaDataDimensionSpacePoint::fromCoordinates($ccordinates);
+ }
+
+ public function getDimensionSpacePointChain(?MetaDataDimensionSpacePoint $dimensionSpacePoint = null): MetaDataDimensionSpacePoints
+ {
+ if ($dimensionSpacePoint === null) {
+ $dimensionSpacePoint = $this->getDefaultDimensionSpacePoint();
+ }
+
+ if ($dimensionSpacePoint->coordinates === []) {
+ return MetaDataDimensionSpacePoints::create($dimensionSpacePoint);
+ }
+
+ // For each coordinate, resolve the ordered fallback chain from the matching preset
+ $perDimensionChains = [];
+ foreach ($dimensionSpacePoint->coordinates as $dimensionName => $primaryValue) {
+ $chain = [$primaryValue]; // safe default: just the value itself
+ foreach ($this->getAllPresets()[$dimensionName]['presets'] ?? [] as $preset) {
+ $values = $preset['values'] ?? null;
+ if ($values !== null && $values[0] === $primaryValue) {
+ $chain = $values;
+ break;
+ }
+ }
+ $perDimensionChains[$dimensionName] = $chain;
+ }
+
+ // Build Cartesian product of all per-dimension chains, tracking fallback distance per combo
+ $combos = [['coords' => [], 'distance' => 0]];
+ foreach ($perDimensionChains as $dimensionName => $chain) {
+ $expanded = [];
+ foreach ($combos as $combo) {
+ foreach ($chain as $index => $value) {
+ $expanded[] = [
+ 'coords' => array_merge($combo['coords'], [$dimensionName => $value]),
+ 'distance' => $combo['distance'] + $index,
+ ];
+ }
+ }
+ $combos = $expanded;
+ }
+
+ // Sort by total fallback distance: most specific (0) first
+ usort($combos, fn ($a, $b) => $a['distance'] <=> $b['distance']);
+
+ $spacePoints = array_map(
+ fn ($combo) => MetaDataDimensionSpacePoint::fromCoordinates($combo['coords']),
+ $combos
+ );
+
+ return MetaDataDimensionSpacePoints::create(...$spacePoints);
+ }
+
+ public function isDimensionSpacePointValid(MetaDataDimensionSpacePoint $dimensionSpacePoint): bool
+ {
+ if (empty($this->getAllPresets())) {
+ return $dimensionSpacePoint->coordinates === [];
+ }
+
+ if (count($dimensionSpacePoint->coordinates) !== count($this->getAllPresets())) {
+ return false;
+ }
+
+ $presetIdentifiers = [];
+ foreach ($dimensionSpacePoint->coordinates as $dimensionName => $value) {
+ foreach ($this->getAllPresets()[$dimensionName]['presets'] ?? [] as $presetIdentifier => $preset) {
+ $values = $preset['values'] ?? null;
+ if ($values !== null && $values[0] === $value) {
+ $presetIdentifiers[$dimensionName] = $presetIdentifier;
+ break;
+ }
+ }
+ if (!isset($presetIdentifiers[$dimensionName])) {
+ return false;
+ }
+ }
+
+ return $this->configurationContentDimensionPresetSource->isPresetCombinationAllowedByConstraints($presetIdentifiers);
+ }
+
+ /**
+ * The cartesian product of all configured dimension presets.
+ *
+ * A coordinate is the primary value of a preset, not its identifier – the two are usually the same
+ * but need not be (a preset "german" can have the values ["de"]). Everything else in this class
+ * works with values: the fallback chain matches on `values[0]` and the default dimension space point
+ * is built from the `default` of each dimension.
+ *
+ * Presets without values are skipped, because they could never be matched anyway.
+ */
+ /**
+ * @param array
+ * }>
+ * }> $input
+ * @return list>
+ */
+ function createAllPresetCombinations(array $input): array
+ {
+ $result = [[]];
+ foreach ($input as $dimensionName => $dimensionConfig) {
+ $append = [];
+ foreach ($dimensionConfig['presets'] ?? [] as $preset) {
+ if (!isset($preset['values'][0])) {
+ continue;
+ }
+ foreach ($result as $coordinates) {
+ $append[] = $coordinates + [$dimensionName => (string) $preset['values'][0]];
+ }
+ }
+ $result = $append;
+ }
+
+ return $result;
+ }
+}
diff --git a/Classes/Domain/Collection/MetaDataCollection.php b/Classes/Domain/Collection/MetaDataCollection.php
deleted file mode 100644
index 5ed8a8f..0000000
--- a/Classes/Domain/Collection/MetaDataCollection.php
+++ /dev/null
@@ -1,21 +0,0 @@
-properties = Arrays::arrayMergeRecursiveOverrule($this->properties, $properties);
- }
-}
diff --git a/Classes/Domain/Dto/Asset.php b/Classes/Domain/Dto/Asset.php
deleted file mode 100644
index ec77a7a..0000000
--- a/Classes/Domain/Dto/Asset.php
+++ /dev/null
@@ -1,98 +0,0 @@
- '',
- 'CopyrightNotice' => '',
- 'Collections' => [],
- 'FileName' => '',
- 'Identifier' => '',
- 'Tags' => [],
- 'Title' => '',
- 'AssetObject' => null,
- ];
-
- /**
- * @return string
- */
- public function getIdentifier()
- {
- return $this->properties['Identifier'];
- }
-
- /**
- * @return string
- */
- public function getCaption()
- {
- return $this->properties['Caption'];
- }
-
- /**
- * @return string
- */
- public function getCopyrightNotice()
- {
- return $this->properties['CopyrightNotice'];
- }
-
- /**
- * @return array
- */
- public function getCollections()
- {
- return $this->properties['Collections'];
- }
-
- /**
- * @return string
- */
- public function getFileName()
- {
- return $this->properties['FileName'];
- }
-
- /**
- * @return array
- */
- public function getTags()
- {
- return $this->properties['Tags'];
- }
-
- /**
- * @return string
- */
- public function getTitle()
- {
- return $this->properties['Title'];
- }
-
- /**
- * @return AssetInterface
- */
- public function getAssetObject()
- {
- return $this->properties['AssetObject'];
- }
-}
diff --git a/Classes/Domain/Dto/Exif.php b/Classes/Domain/Dto/Exif.php
deleted file mode 100644
index 4d3b0bc..0000000
--- a/Classes/Domain/Dto/Exif.php
+++ /dev/null
@@ -1,1179 +0,0 @@
- 0,
- 'ImageLength' => 0,
- 'BitsPerSample' => [0, 0, 0],
- 'Compression' => '',
- 'PhotometricInterpretation' => '',
- 'Orientation' => '',
- 'SamplesPerPixel' => 0,
- 'PlanarConfiguration' => '',
- 'YCbCrSubSampling' => '',
- 'YCbCrPositioning' => '',
- 'XResolution' => 0.0,
- 'YResolution' => 0.0,
- 'ResolutionUnit' => '',
- 'StripOffsets' => [],
- 'RowsPerStrip' => 0,
- 'StripByteCounts' => [],
- 'JPEGInterchangeFormat' => 0,
- 'JPEGInterchangeFormatLength' => 0,
- 'TransferFunction' => [],
- 'WhitePoint' => [],
- 'PrimaryChromaticities' => [],
- 'YCbCrCoefficients' => [],
- 'ReferenceBlackWhite' => [],
- 'DateTime' => null,
- 'ImageDescription' => '',
- 'Make' => '',
- 'Model' => '',
- 'Software' => '',
- 'Artist' => '',
- 'Copyright' => '',
- 'ExifVersion' => '',
- 'FlashpixVersion' => '',
- 'ColorSpace' => '',
- 'Gamma' => 0.0,
- 'ComponentsConfiguration' => '',
- 'CompressedBitsPerPixel' => 0.0,
- 'PixelXDimension' => 0,
- 'PixelYDimension' => 0,
- 'MakerNote' => '',
- 'UserComment' => '',
- 'RelatedSoundFile' => '',
- 'DateTimeOriginal' => null,
- 'DateTimeDigitized' => null,
- 'ExposureTime' => 0.0,
- 'FNumber' => 0.0,
- 'ExposureProgram' => '',
- 'SpectralSensitivity' => '',
- 'PhotographicSensitivity' => 0,
- 'OECF' => [],
- 'SensitivityType' => '',
- 'StandardOutputSensitivity' => 0,
- 'RecommendedExposureIndex' => 0,
- 'ISOSpeed' => 0,
- 'ISOSpeedLatitudeyyy' => 0,
- 'ISOSpeedLatitudezzz' => 0,
- 'ShutterSpeedValue' => 0.0,
- 'ApertureValue' => 0.0,
- 'BrightnessValue' => 0.0,
- 'ExposureBiasValue' => 0.0,
- 'MaxApertureValue' => 0.0,
- 'SubjectDistance' => 0,
- 'MeteringMode' => '',
- 'LightSource' => '',
- 'Flash' => '',
- 'FocalLength' => 0.0,
- 'SubjectArea' => [0, 0],
- 'FlashEnergy' => 0.0,
- 'SpatialFrequencyResponse' => [],
- 'FocalPlaneXResolution' => 0.0,
- 'FocalPlaneYResolution' => 0.0,
- 'FocalPlaneResolutionUnit' => '',
- 'SubjectLocation' => [0, 0],
- 'ExposureIndex' => 0.0,
- 'SensingMethod' => '',
- 'FileSource' => '',
- 'SceneType' => '',
- 'CFAPattern' => [],
- 'CustomRendered' => '',
- 'ExposureMode' => '',
- 'WhiteBalance' => '',
- 'DigitalZoomRatio' => 0.0,
- 'FocalLengthIn35mmFilm' => 0,
- 'SceneCaptureType' => '',
- 'GainControl' => '',
- 'Contrast' => '',
- 'Saturation' => '',
- 'Sharpness' => '',
- 'DeviceSettingDescription' => [],
- 'SubjectDistanceRange' => '',
- 'Temperature' => 0.0,
- 'Humidity' => 0.0,
- 'Pressure' => 0.0,
- 'WaterDepth' => 0.0,
- 'Acceleration' => 0.0,
- 'CameraElevationAngle' => 0.0,
- 'ImageUniqueID' => '',
- 'CameraOwnerName' => '',
- 'BodySerialNumber' => '',
- 'LensSpecification' => [
- 0.0, // Minimum focal length (mm)
- 0.0, // Maximum focal length (mm)
- 0.0, // Minimum F number in the minimum focal length
- 0.0 // Minimum F number in the maximum focal length
- ],
- 'LensMake' => '',
- 'LensModel' => '',
- 'LensSerialNumber' => '',
- 'GPSVersionID' => '',
- 'GPSLatitude' => 0.0,
- 'GPSLongitude' => 0.0,
- 'GPSAltitude' => 0.0,
- 'GPSSatellites' => '',
- 'GPSStatus' => '',
- 'GPSMeasureMode' => '',
- 'GPSDOP' => 0.0,
- 'GPSSpeedRef' => '',
- 'GPSSpeed' => 0.0,
- 'GPSTrackRef' => '',
- 'GPSTrack' => 0.0,
- 'GPSImgDirectionRef' => '',
- 'GPSImgDirection' => 0.0,
- 'GPSMapDatum' => '',
- 'GPSDestLatitude' => 0.0,
- 'GPSDestLongitude' => 0.0,
- 'GPSDestBearingRef' => '',
- 'GPSDestBearing' => 0.0,
- 'GPSDestDistanceRef' => '',
- 'GPSDestDistance' => 0.0,
- 'GPSProcessingMethod' => '',
- 'GPSAreaInformation' => '',
- 'GPSDateTimeStamp' => null,
- 'GPSDifferential' => '',
- 'GPSHPositioningError' => 0.0,
- ];
-
- /**
- * @return int
- */
- public function getImageWidth()
- {
- return $this->properties['ImageWidth'];
- }
-
- /**
- * @return int
- */
- public function getImageLength()
- {
- return $this->properties['ImageLength'];
- }
-
- /**
- * @return array
- */
- public function getBitsPerSample()
- {
- return $this->properties['BitsPerSample'];
- }
-
- /**
- * @return string
- */
- public function getCompression()
- {
- return $this->properties['Compression'];
- }
-
- /**
- * @return string
- */
- public function getPhotometricInterpretation()
- {
- return $this->properties['PhotometricInterpretation'];
- }
-
- /**
- * @return string
- */
- public function getOrientation()
- {
- return $this->properties['Orientation'];
- }
-
- /**
- * @return int
- */
- public function getSamplesPerPixel()
- {
- return $this->properties['SamplesPerPixel'];
- }
-
- /**
- * @return string
- */
- public function getPlanarConfiguration()
- {
- return $this->properties['PlanarConfiguration'];
- }
-
- /**
- * @return string
- */
- public function getYCbCrSubSampling()
- {
- return $this->properties['YCbCrSubSampling'];
- }
-
- /**
- * @return string
- */
- public function getYCbCrPositioning()
- {
- return $this->properties['YCbCrPositioning'];
- }
-
- /**
- * @return float
- */
- public function getXResolution()
- {
- return $this->properties['XResolution'];
- }
-
- /**
- * @return float
- */
- public function getYResolution()
- {
- return $this->properties['YResolution'];
- }
-
- /**
- * @return string
- */
- public function getResolutionUnit()
- {
- return $this->properties['ResolutionUnit'];
- }
-
- /**
- * @return array
- */
- public function getStripOffsets()
- {
- return $this->properties['StripOffsets'];
- }
-
- /**
- * @return int
- */
- public function getRowsPerStrip()
- {
- return $this->properties['RowsPerStrip'];
- }
-
- /**
- * @return array
- */
- public function getStripByteCounts()
- {
- return $this->properties['StripByteCounts'];
- }
-
- /**
- * @return int
- */
- public function getJPEGInterchangeFormat()
- {
- return $this->properties['JPEGInterchangeFormat'];
- }
-
- /**
- * @return int
- */
- public function getJPEGInterchangeFormatLength()
- {
- return $this->properties['JPEGInterchangeFormatLength'];
- }
-
- /**
- * @return array
- */
- public function getTransferFunction()
- {
- return $this->properties['TransferFunction'];
- }
-
- /**
- * @return array
- */
- public function getWhitePoint()
- {
- return $this->properties['WhitePoint'];
- }
-
- /**
- * @return array
- */
- public function getPrimaryChromaticities()
- {
- return $this->properties['PrimaryChromaticities'];
- }
-
- /**
- * @return array
- */
- public function getYCbCrCoefficients()
- {
- return $this->properties['YCbCrCoefficients'];
- }
-
- /**
- * @return array
- */
- public function getReferenceBlackWhite()
- {
- return $this->properties['ReferenceBlackWhite'];
- }
-
- /**
- * @return \DateTime
- */
- public function getDateTime()
- {
- return $this->properties['DateTime'];
- }
-
- /**
- * @return string
- */
- public function getImageDescription()
- {
- return $this->properties['ImageDescription'];
- }
-
- /**
- * @return string
- */
- public function getMake()
- {
- return $this->properties['Make'];
- }
-
- /**
- * @return string
- */
- public function getModel()
- {
- return $this->properties['Model'];
- }
-
- /**
- * @return string
- */
- public function getSoftware()
- {
- return $this->properties['Software'];
- }
-
- /**
- * @return string
- */
- public function getArtist()
- {
- return $this->properties['Artist'];
- }
-
- /**
- * @return string
- */
- public function getCopyright()
- {
- return $this->properties['Copyright'];
- }
-
- /**
- * @return string
- */
- public function getExifVersion()
- {
- return $this->properties['ExifVersion'];
- }
-
- /**
- * @return string
- */
- public function getFlashpixVersion()
- {
- return $this->properties['FlashpixVersion'];
- }
-
- /**
- * @return string
- */
- public function getColorSpace()
- {
- return $this->properties['ColorSpace'];
- }
-
- /**
- * @return float
- */
- public function getGamma()
- {
- return $this->properties['Gamma'];
- }
-
- /**
- * @return string
- */
- public function getComponentsConfiguration()
- {
- return $this->properties['ComponentsConfiguration'];
- }
-
- /**
- * @return float
- */
- public function getCompressedBitsPerPixel()
- {
- return $this->properties['CompressedBitsPerPixel'];
- }
-
- /**
- * @return int
- */
- public function getPixelXDimension()
- {
- return $this->properties['PixelXDimension'];
- }
-
- /**
- * @return int
- */
- public function getPixelYDimension()
- {
- return $this->properties['PixelYDimension'];
- }
-
- /**
- * @return string
- */
- public function getMakerNote()
- {
- return $this->properties['MakerNote'];
- }
-
- /**
- * @return string
- */
- public function getUserComment()
- {
- return $this->properties['UserComment'];
- }
-
- /**
- * @return string
- */
- public function getRelatedSoundFile()
- {
- return $this->properties['RelatedSoundFile'];
- }
-
- /**
- * @return \DateTime
- */
- public function getDateTimeOriginal()
- {
- return $this->properties['DateTimeOriginal'];
- }
-
- /**
- * @return \DateTime
- */
- public function getDateTimeDigitized()
- {
- return $this->properties['DateTimeDigitized'];
- }
-
- /**
- * @return float
- */
- public function getExposureTime()
- {
- return $this->properties['ExposureTime'];
- }
-
- /**
- * @return float
- */
- public function getFNumber()
- {
- return $this->properties['FNumber'];
- }
-
- /**
- * @return string
- */
- public function getExposureProgram()
- {
- return $this->properties['ExposureProgram'];
- }
-
- /**
- * @return string
- */
- public function getSpectralSensitivity()
- {
- return $this->properties['SpectralSensitivity'];
- }
-
- /**
- * @return int
- */
- public function getPhotographicSensitivity()
- {
- return $this->properties['PhotographicSensitivity'];
- }
-
- /**
- * @return array
- */
- public function getOECF()
- {
- return $this->properties['OECF'];
- }
-
- /**
- * @return string
- */
- public function getSensitivityType()
- {
- return $this->properties['SensitivityType'];
- }
-
- /**
- * @return int
- */
- public function getStandardOutputSensitivity()
- {
- return $this->properties['StandardOutputSensitivity'];
- }
-
- /**
- * @return int
- */
- public function getRecommendedExposureIndex()
- {
- return $this->properties['RecommendedExposureIndex'];
- }
-
- /**
- * @return int
- */
- public function getISOSpeed()
- {
- return $this->properties['ISOSpeed'];
- }
-
- /**
- * @return int
- */
- public function getISOSpeedLatitudeyyy()
- {
- return $this->properties['ISOSpeedLatitudeyyy'];
- }
-
- /**
- * @return int
- */
- public function getISOSpeedLatitudezzz()
- {
- return $this->properties['ISOSpeedLatitudezzz'];
- }
-
- /**
- * @return float
- */
- public function getShutterSpeedValue()
- {
- return $this->properties['ShutterSpeedValue'];
- }
-
- /**
- * @return float
- */
- public function getApertureValue()
- {
- return $this->properties['ApertureValue'];
- }
-
- /**
- * @return float
- */
- public function getBrightnessValue()
- {
- return $this->properties['BrightnessValue'];
- }
-
- /**
- * @return float
- */
- public function getExposureBiasValue()
- {
- return $this->properties['ExposureBiasValue'];
- }
-
- /**
- * @return float
- */
- public function getMaxApertureValue()
- {
- return $this->properties['MaxApertureValue'];
- }
-
- /**
- * @return int
- */
- public function getSubjectDistance()
- {
- return $this->properties['SubjectDistance'];
- }
-
- /**
- * @return string
- */
- public function getMeteringMode()
- {
- return $this->properties['MeteringMode'];
- }
-
- /**
- * @return string
- */
- public function getLightSource()
- {
- return $this->properties['LightSource'];
- }
-
- /**
- * @return string
- */
- public function getFlash()
- {
- return $this->properties['Flash'];
- }
-
- /**
- * @return float
- */
- public function getFocalLength()
- {
- return $this->properties['FocalLength'];
- }
-
- /**
- * @return array
- */
- public function getSubjectArea()
- {
- return $this->properties['SubjectArea'];
- }
-
- /**
- * @return float
- */
- public function getFlashEnergy()
- {
- return $this->properties['FlashEnergy'];
- }
-
- /**
- * @return array
- */
- public function getSpatialFrequencyResponse()
- {
- return $this->properties['SpatialFrequencyResponse'];
- }
-
- /**
- * @return float
- */
- public function getFocalPlaneXResolution()
- {
- return $this->properties['FocalPlaneXResolution'];
- }
-
- /**
- * @return float
- */
- public function getFocalPlaneYResolution()
- {
- return $this->properties['FocalPlaneYResolution'];
- }
-
- /**
- * @return string
- */
- public function getFocalPlaneResolutionUnit()
- {
- return $this->properties['FocalPlaneResolutionUnit'];
- }
-
- /**
- * @return array
- */
- public function getSubjectLocation()
- {
- return $this->properties['SubjectLocation'];
- }
-
- /**
- * @return float
- */
- public function getExposureIndex()
- {
- return $this->properties['ExposureIndex'];
- }
-
- /**
- * @return string
- */
- public function getSensingMethod()
- {
- return $this->properties['SensingMethod'];
- }
-
- /**
- * @return string
- */
- public function getFileSource()
- {
- return $this->properties['FileSource'];
- }
-
- /**
- * @return string
- */
- public function getSceneType()
- {
- return $this->properties['SceneType'];
- }
-
- /**
- * @return array
- */
- public function getCFAPattern()
- {
- return $this->properties['CFAPattern'];
- }
-
- /**
- * @return string
- */
- public function getCustomRendered()
- {
- return $this->properties['CustomRendered'];
- }
-
- /**
- * @return string
- */
- public function getExposureMode()
- {
- return $this->properties['ExposureMode'];
- }
-
- /**
- * @return string
- */
- public function getWhiteBalance()
- {
- return $this->properties['WhiteBalance'];
- }
-
- /**
- * @return float
- */
- public function getDigitalZoomRatio()
- {
- return $this->properties['DigitalZoomRatio'];
- }
-
- /**
- * @return int
- */
- public function getFocalLengthIn35mmFilm()
- {
- return $this->properties['FocalLengthIn35mmFilm'];
- }
-
- /**
- * @return string
- */
- public function getSceneCaptureType()
- {
- return $this->properties['SceneCaptureType'];
- }
-
- /**
- * @return string
- */
- public function getGainControl()
- {
- return $this->properties['GainControl'];
- }
-
- /**
- * @return string
- */
- public function getContrast()
- {
- return $this->properties['Contrast'];
- }
-
- /**
- * @return string
- */
- public function getSaturation()
- {
- return $this->properties['Saturation'];
- }
-
- /**
- * @return string
- */
- public function getSharpness()
- {
- return $this->properties['Sharpness'];
- }
-
- /**
- * @return array
- */
- public function getDeviceSettingDescription()
- {
- return $this->properties['DeviceSettingDescription'];
- }
-
- /**
- * @return string
- */
- public function getSubjectDistanceRange()
- {
- return $this->properties['SubjectDistanceRange'];
- }
-
- /**
- * @return float
- */
- public function getTemperature()
- {
- return $this->properties['Temperature'];
- }
-
- /**
- * @return float
- */
- public function getHumidity()
- {
- return $this->properties['Humidity'];
- }
-
- /**
- * @return float
- */
- public function getPressure()
- {
- return $this->properties['Pressure'];
- }
-
- /**
- * @return float
- */
- public function getWaterDepth()
- {
- return $this->properties['WaterDepth'];
- }
-
- /**
- * @return float
- */
- public function getAcceleration()
- {
- return $this->properties['Acceleration'];
- }
-
- /**
- * @return float
- */
- public function getCameraElevationAngle()
- {
- return $this->properties['CameraElevationAngle'];
- }
-
- /**
- * @return string
- */
- public function getImageUniqueID()
- {
- return $this->properties['ImageUniqueID'];
- }
-
- /**
- * @return string
- */
- public function getCameraOwnerName()
- {
- return $this->properties['CameraOwnerName'];
- }
-
- /**
- * @return string
- */
- public function getBodySerialNumber()
- {
- return $this->properties['BodySerialNumber'];
- }
-
- /**
- * @return array
- */
- public function getLensSpecification()
- {
- return $this->properties['LensSpecification'];
- }
-
- /**
- * @return string
- */
- public function getLensMake()
- {
- return $this->properties['LensMake'];
- }
-
- /**
- * @return string
- */
- public function getLensModel()
- {
- return $this->properties['LensModel'];
- }
-
- /**
- * @return string
- */
- public function getLensSerialNumber()
- {
- return $this->properties['LensSerialNumber'];
- }
-
- /**
- * @return string
- */
- public function getGPSVersionID()
- {
- return $this->properties['GPSVersionID'];
- }
-
- /**
- * @return float
- */
- public function getGPSLatitude()
- {
- return $this->properties['GPSLatitude'];
- }
-
- /**
- * @return float
- */
- public function getGPSLongitude()
- {
- return $this->properties['GPSLongitude'];
- }
-
- /**
- * @return float
- */
- public function getGPSAltitude()
- {
- return $this->properties['GPSAltitude'];
- }
-
- /**
- * @return string
- */
- public function getGPSSatellites()
- {
- return $this->properties['GPSSatellites'];
- }
-
- /**
- * @return string
- */
- public function getGPSStatus()
- {
- return $this->properties['GPSStatus'];
- }
-
- /**
- * @return string
- */
- public function getGPSMeasureMode()
- {
- return $this->properties['GPSMeasureMode'];
- }
-
- /**
- * @return float
- */
- public function getGPSDOP()
- {
- return $this->properties['GPSDOP'];
- }
-
- /**
- * @return string
- */
- public function getGPSSpeedRef()
- {
- return $this->properties['GPSSpeedRef'];
- }
-
- /**
- * @return float
- */
- public function getGPSSpeed()
- {
- return $this->properties['GPSSpeed'];
- }
-
- /**
- * @return string
- */
- public function getGPSTrackRef()
- {
- return $this->properties['GPSTrackRef'];
- }
-
- /**
- * @return float
- */
- public function getGPSTrack()
- {
- return $this->properties['GPSTrack'];
- }
-
- /**
- * @return string
- */
- public function getGPSImgDirectionRef()
- {
- return $this->properties['GPSImgDirectionRef'];
- }
-
- /**
- * @return float
- */
- public function getGPSImgDirection()
- {
- return $this->properties['GPSImgDirection'];
- }
-
- /**
- * @return string
- */
- public function getGPSMapDatum()
- {
- return $this->properties['GPSMapDatum'];
- }
-
- /**
- * @return float
- */
- public function getGPSDestLatitude()
- {
- return $this->properties['GPSDestLatitude'];
- }
-
- /**
- * @return float
- */
- public function getGPSDestLongitude()
- {
- return $this->properties['GPSDestLongitude'];
- }
-
- /**
- * @return string
- */
- public function getGPSDestBearingRef()
- {
- return $this->properties['GPSDestBearingRef'];
- }
-
- /**
- * @return float
- */
- public function getGPSDestBearing()
- {
- return $this->properties['GPSDestBearing'];
- }
-
- /**
- * @return string
- */
- public function getGPSDestDistanceRef()
- {
- return $this->properties['GPSDestDistanceRef'];
- }
-
- /**
- * @return float
- */
- public function getGPSDestDistance()
- {
- return $this->properties['GPSDestDistance'];
- }
-
- /**
- * @return string
- */
- public function getGPSProcessingMethod()
- {
- return $this->properties['GPSProcessingMethod'];
- }
-
- /**
- * @return string
- */
- public function getGPSAreaInformation()
- {
- return $this->properties['GPSAreaInformation'];
- }
-
- /**
- * @return \DateTime
- */
- public function getGPSDateTimeStamp()
- {
- return $this->properties['GPSDateTimeStamp'];
- }
-
- /**
- * @return string
- */
- public function getGPSDifferential()
- {
- return $this->properties['GPSDifferential'];
- }
-
- /**
- * @return float
- */
- public function getGPSHPositioningError()
- {
- return $this->properties['GPSHPositioningError'];
- }
-}
diff --git a/Classes/Domain/Dto/Iptc.php b/Classes/Domain/Dto/Iptc.php
deleted file mode 100644
index a6b0176..0000000
--- a/Classes/Domain/Dto/Iptc.php
+++ /dev/null
@@ -1,229 +0,0 @@
- '',
- 'Contact' => [],
- 'CopyrightNotice' => '',
- 'Country' => '',
- 'CountryCode' => '',
- 'CreationDate' => null,
- 'Creator' => [],
- 'CreatorTitle' => [],
- 'CreditLine' => '',
- 'DeprecatedCategories' => [],
- 'Description' => '',
- 'DescriptionWriter' => [],
- 'DigitalCreationDate' => null,
- 'Headline' => '',
- 'Instructions' => '',
- 'IntellectualGenres' => [],
- 'JobId' => '',
- 'Keywords' => [],
- 'Source' => '',
- 'State' => '',
- 'SubjectCodes' => [],
- 'Sublocation' => '',
- 'Title' => '',
- ];
-
- /**
- * @return string
- */
- public function getCity()
- {
- return $this->properties['City'];
- }
-
- /**
- * @return array
- */
- public function getContact()
- {
- return $this->properties['Contact'];
- }
-
- /**
- * @return string
- */
- public function getCopyrightNotice()
- {
- return $this->properties['CopyrightNotice'];
- }
-
- /**
- * @return string
- */
- public function getCountry()
- {
- return $this->properties['Country'];
- }
-
- /**
- * @return string
- */
- public function getCountryCode()
- {
- return $this->properties['CountryCode'];
- }
-
- /**
- * @return \DateTime
- */
- public function getCreationDate()
- {
- return $this->properties['CreationDate'];
- }
-
- /**
- * @return array
- */
- public function getCreator()
- {
- return $this->properties['Creator'];
- }
-
- /**
- * @return array
- */
- public function getCreatorTitle()
- {
- return $this->properties['CreatorTitle'];
- }
-
- /**
- * @return string
- */
- public function getCreditLine()
- {
- return $this->properties['CreditLine'];
- }
-
- /**
- * @return array
- */
- public function getDeprecatedCategories()
- {
- return $this->properties['DeprecatedCategories'];
- }
-
- /**
- * @return string
- */
- public function getDescription()
- {
- return $this->properties['Description'];
- }
-
- /**
- * @return array
- */
- public function getDescriptionWriter()
- {
- return $this->properties['DescriptionWriter'];
- }
-
- /**
- * @return \DateTime
- */
- public function getDigitalCreationDate()
- {
- return $this->properties['DigitalCreationDate'];
- }
-
- /**
- * @return string
- */
- public function getHeadline()
- {
- return $this->properties['Headline'];
- }
-
- /**
- * @return string
- */
- public function getInstructions()
- {
- return $this->properties['Instructions'];
- }
-
- /**
- * @return array
- */
- public function getIntellectualGenres()
- {
- return $this->properties['IntellectualGenres'];
- }
-
- /**
- * @return string
- */
- public function getJobId()
- {
- return $this->properties['JobId'];
- }
-
- /**
- * @return array
- */
- public function getKeywords()
- {
- return $this->properties['Keywords'];
- }
-
- /**
- * @return string
- */
- public function getSource()
- {
- return $this->properties['Source'];
- }
-
- /**
- * @return string
- */
- public function getState()
- {
- return $this->properties['State'];
- }
-
- /**
- * @return array
- */
- public function getSubjectCodes()
- {
- return $this->properties['SubjectCodes'];
- }
-
- /**
- * @return string
- */
- public function getSublocation()
- {
- return $this->properties['Sublocation'];
- }
-
- /**
- * @return string
- */
- public function getTitle()
- {
- return $this->properties['Title'];
- }
-}
diff --git a/Classes/Domain/Dto/MetaDataAssetFilter.php b/Classes/Domain/Dto/MetaDataAssetFilter.php
new file mode 100644
index 0000000..6d54bc7
--- /dev/null
+++ b/Classes/Domain/Dto/MetaDataAssetFilter.php
@@ -0,0 +1,58 @@
+ DimensionValue.
+ * E.g.: ["language" => "es", "country" => "ar"]
+ */
+final readonly class MetaDataDimensionSpacePoint implements Stringable {
+
+ /**
+ * @param array $coordinates
+ * @param string $hash
+ */
+ private function __construct(
+ public array $coordinates,
+ public string $hash,
+ ) {
+ }
+
+ /**
+ * @param array $coordinates
+ */
+ private static function hashCoordinates(array $coordinates): string
+ {
+ $identityComponents = $coordinates;
+ ksort($identityComponents);
+ try {
+ return md5(json_encode($identityComponents, JSON_THROW_ON_ERROR));
+ } catch (JsonException $e) {
+ throw new \InvalidArgumentException('Failed to hash coordinates: ' . $e->getMessage(), 1776251973, $e);
+ }
+ }
+
+ /**
+ * @param array $coordinates
+ */
+ public static function fromCoordinates(array $coordinates): self
+ {
+ return new self(
+ $coordinates,
+ self::hashCoordinates($coordinates),
+ );
+ }
+
+ public function equals(self $other): bool
+ {
+ return $this->hash === $other->hash;
+ }
+
+ public function __toString(): string
+ {
+ return (string) json_encode($this->coordinates);
+ }
+}
diff --git a/Classes/Domain/Dto/MetaDataDimensionSpacePoints.php b/Classes/Domain/Dto/MetaDataDimensionSpacePoints.php
new file mode 100644
index 0000000..60d0a44
--- /dev/null
+++ b/Classes/Domain/Dto/MetaDataDimensionSpacePoints.php
@@ -0,0 +1,60 @@
+
+ */
+final readonly class MetaDataDimensionSpacePoints implements IteratorAggregate, Countable {
+
+ /**
+ * @param list $spacePoints
+ */
+ private function __construct(
+ private array $spacePoints,
+ ) {
+ }
+
+ public static function create(MetaDataDimensionSpacePoint ...$spacePoints): self
+ {
+ return new self(array_values($spacePoints));
+ }
+
+ public function include(MetaDataDimensionSpacePoint $dimensionSpacePoint): bool
+ {
+ foreach ($this->spacePoints as $spacePoint) {
+ if ($spacePoint->equals($dimensionSpacePoint)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public function getIterator(): Traversable
+ {
+ yield from $this->spacePoints;
+ }
+
+ /**
+ * @template T
+ * @param Closure(MetaDataDimensionSpacePoint): T $callback
+ * @return T[]
+ */
+ public function map(Closure $callback): array
+ {
+ return array_map($callback, $this->spacePoints);
+ }
+
+ public function count(): int
+ {
+ return count($this->spacePoints);
+ }
+}
diff --git a/Classes/Domain/Dto/MetaDataEditorDefinition.php b/Classes/Domain/Dto/MetaDataEditorDefinition.php
new file mode 100644
index 0000000..e3bac14
--- /dev/null
+++ b/Classes/Domain/Dto/MetaDataEditorDefinition.php
@@ -0,0 +1,31 @@
+ $options Editor specific options (e.g. ['rows' => 3])
+ */
+ private function __construct(
+ public string|null $editorType,
+ public array $options,
+ ) {
+ }
+
+ public static function default(): self
+ {
+ return new self(null, []);
+ }
+
+ /**
+ * @param array $options Editor specific options (e.g. ['rows' => 3])
+ */
+ public static function create(string|null $editorType, array $options): self
+ {
+ return new self($editorType, $options);
+ }
+}
diff --git a/Classes/Domain/Dto/MetaDataGlobalScope.php b/Classes/Domain/Dto/MetaDataGlobalScope.php
new file mode 100644
index 0000000..f926f8a
--- /dev/null
+++ b/Classes/Domain/Dto/MetaDataGlobalScope.php
@@ -0,0 +1,31 @@
+
+ */
+final readonly class MetaDataPropertyDefinitions implements IteratorAggregate {
+
+ /**
+ * @param array $propertyDefinitionsByName
+ */
+ private function __construct(
+ private array $propertyDefinitionsByName,
+ ) {
+ }
+
+ public static function create(MetaDataPropertyDefinition ...$propertyDefinitions): self
+ {
+ $propertyDefinitionsByName = [];
+ foreach ($propertyDefinitions as $propertyDefinition) {
+ $propertyDefinitionsByName[$propertyDefinition->name->value] = $propertyDefinition;
+ }
+ return new self($propertyDefinitionsByName);
+ }
+
+ public function include(MetaDataPropertyName $propertyName): bool
+ {
+ return array_key_exists($propertyName->value, $this->propertyDefinitionsByName);
+ }
+
+ public function get(MetaDataPropertyName $propertyName): MetaDataPropertyDefinition
+ {
+ if (!array_key_exists($propertyName->value, $this->propertyDefinitionsByName)) {
+ throw new InvalidArgumentException(sprintf('Metadata property "%s" is not defined', $propertyName), 1776278182);
+ }
+ return $this->propertyDefinitionsByName[$propertyName->value];
+ }
+
+ public function getIterator(): Traversable
+ {
+ yield from $this->propertyDefinitionsByName;
+ }
+}
diff --git a/Classes/Domain/Dto/MetaDataPropertyName.php b/Classes/Domain/Dto/MetaDataPropertyName.php
new file mode 100644
index 0000000..48d4539
--- /dev/null
+++ b/Classes/Domain/Dto/MetaDataPropertyName.php
@@ -0,0 +1,33 @@
+value;
+ }
+
+ public function equals(string $propertyName): bool
+ {
+ return $this->value === $propertyName;
+ }
+}
diff --git a/Classes/Domain/Dto/MetaDataPropertyNames.php b/Classes/Domain/Dto/MetaDataPropertyNames.php
new file mode 100644
index 0000000..05d2441
--- /dev/null
+++ b/Classes/Domain/Dto/MetaDataPropertyNames.php
@@ -0,0 +1,77 @@
+
+ */
+final readonly class MetaDataPropertyNames implements IteratorAggregate, Countable
+{
+ /**
+ * @param list $propertyNames
+ */
+ private function __construct(
+ private array $propertyNames,
+ ) {
+ }
+
+ public static function create(MetaDataPropertyName|string ...$propertyNames): self
+ {
+ return new self(array_values(array_map(
+ static fn (MetaDataPropertyName|string $propertyName) => is_string($propertyName)
+ ? MetaDataPropertyName::fromString($propertyName)
+ : $propertyName,
+ $propertyNames,
+ )));
+ }
+
+ public static function createEmpty(): self
+ {
+ return new self([]);
+ }
+
+ public function include(MetaDataPropertyName $propertyName): bool
+ {
+ foreach ($this->propertyNames as $existingPropertyName) {
+ if ($existingPropertyName->equals($propertyName->value)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public function isEmpty(): bool
+ {
+ return $this->propertyNames === [];
+ }
+
+ /**
+ * @template T
+ * @param Closure(MetaDataPropertyName): T $callback
+ * @return T[]
+ */
+ public function map(Closure $callback): array
+ {
+ return array_map($callback, $this->propertyNames);
+ }
+
+ public function getIterator(): Traversable
+ {
+ yield from $this->propertyNames;
+ }
+
+ public function count(): int
+ {
+ return count($this->propertyNames);
+ }
+}
diff --git a/Classes/Domain/Dto/MetaDataPropertyType.php b/Classes/Domain/Dto/MetaDataPropertyType.php
new file mode 100644
index 0000000..4548739
--- /dev/null
+++ b/Classes/Domain/Dto/MetaDataPropertyType.php
@@ -0,0 +1,93 @@
+tryCoerce($value);
+ if ($coerced === null) {
+ throw new InvalidArgumentException(sprintf('Value %s cannot be interpreted as %s', json_encode($value), $this->name), 1785715201);
+ }
+ return is_bool($coerced) ? ($coerced ? '1' : '0') : (string)$coerced;
+ }
+
+ /**
+ * The given stored value as this type, or NULL if it cannot be interpreted as one
+ */
+ public function fromStoredValue(string|int|bool $value): string|int|bool|null
+ {
+ return $this->tryCoerce($value);
+ }
+
+ // -----------------------
+
+ private function tryCoerce(string|int|bool $value): string|int|bool|null
+ {
+ return match ($this) {
+ self::string => is_bool($value) ? ($value ? '1' : '0') : (string)$value,
+ self::integer => self::toInteger($value),
+ self::boolean => self::toBoolean($value),
+ };
+ }
+
+ private static function toInteger(string|int|bool $value): ?int
+ {
+ if (is_int($value)) {
+ return $value;
+ }
+ if (is_bool($value)) {
+ return $value ? 1 : 0;
+ }
+ $trimmed = trim($value);
+ return preg_match('/^-?\d+$/', $trimmed) === 1 ? (int)$trimmed : null;
+ }
+
+ private static function toBoolean(string|int|bool $value): ?bool
+ {
+ if (is_bool($value)) {
+ return $value;
+ }
+ if (is_int($value)) {
+ return match ($value) {
+ 0 => false,
+ 1 => true,
+ default => null,
+ };
+ }
+ return match (strtolower(trim($value))) {
+ '1', 'true', 'on', 'yes' => true,
+ '0', 'false', 'off', 'no' => false,
+ default => null,
+ };
+ }
+}
diff --git a/Classes/Domain/Dto/MetaDataPropertyUiDefinition.php b/Classes/Domain/Dto/MetaDataPropertyUiDefinition.php
new file mode 100644
index 0000000..af45fda
--- /dev/null
+++ b/Classes/Domain/Dto/MetaDataPropertyUiDefinition.php
@@ -0,0 +1,17 @@
+ownValue !== null;
+ }
+
+ /** Fusion getter access */
+ public function getOwnValue(): string|int|bool|null
+ {
+ return $this->ownValue;
+ }
+
+ /**
+ * Whether the effective value stems from a fallback dimension space point
+ */
+ public function isInherited(): bool
+ {
+ return $this->ownValue === null && $this->inheritedValue !== null;
+ }
+}
diff --git a/Classes/Domain/Dto/MetaDataPropertyValues.php b/Classes/Domain/Dto/MetaDataPropertyValues.php
new file mode 100644
index 0000000..091276f
--- /dev/null
+++ b/Classes/Domain/Dto/MetaDataPropertyValues.php
@@ -0,0 +1,59 @@
+
+ */
+final readonly class MetaDataPropertyValues implements IteratorAggregate {
+
+ /**
+ * @param array $values
+ */
+ private function __construct(
+ private array $values,
+ ) {
+ }
+
+ public static function createEmpty(): self
+ {
+ return new self([]);
+ }
+
+ public function with(MetaDataPropertyName $propertyName, MetaDataPropertyValue $value): self
+ {
+ return new self([...$this->values, $propertyName->value => $value]);
+ }
+
+ public function get(MetaDataPropertyName $propertyName): MetaDataPropertyValue
+ {
+ if (!array_key_exists($propertyName->value, $this->values)) {
+ throw new InvalidArgumentException(sprintf('Metadata property "%s" is not defined', $propertyName), 1776278183);
+ }
+ return $this->values[$propertyName->value];
+ }
+
+ /**
+ * The effective values by property name, e.g. for rendering
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return array_map(static fn (MetaDataPropertyValue $value) => $value->value, $this->values);
+ }
+
+ public function getIterator(): Traversable
+ {
+ foreach ($this->values as $propertyName => $value) {
+ yield MetaDataPropertyName::fromString($propertyName) => $value;
+ }
+ }
+}
diff --git a/Classes/Helper/AssetMetaDataHelper.php b/Classes/Helper/AssetMetaDataHelper.php
new file mode 100644
index 0000000..6e12176
--- /dev/null
+++ b/Classes/Helper/AssetMetaDataHelper.php
@@ -0,0 +1,54 @@
+ $coordinates dimension coordinates, e.g. ['language' => 'de']. Empty = the default dimension
+ * @return array
+ */
+ public function getMetaData(Asset $asset, array $coordinates = []): array
+ {
+ return $this->metaDataManager->getMetaDataPropertyValues(
+ MetaDataAssetReference::create($asset->assetSourceIdentifier, $asset->getIdentifier()),
+ $coordinates === [] ? null : MetaDataDimensionSpacePoint::fromCoordinates($coordinates),
+ )->toArray();
+ }
+
+ /**
+ * The effective metadata property value of the given asset and property name, with dimension fallbacks applied
+ *
+ * @param array $coordinates dimension coordinates, e.g. ['language' => 'de']. Empty = the default dimension
+ * @return string|int|bool|null Value of the metadata property or NULL if it was not set (or explicitly reset)
+ */
+ public function getMetaDataProperty(Asset $asset, string $propertyName, array $coordinates = []): string|int|bool|null
+ {
+ return $this->metaDataManager->getMetaDataPropertyValue(
+ MetaDataAssetReference::create($asset->assetSourceIdentifier, $asset->getIdentifier()),
+ $propertyName,
+ $coordinates === [] ? null : MetaDataDimensionSpacePoint::fromCoordinates($coordinates),
+ )->value;
+ }
+
+ public function allowsCallOfMethod($methodName): true
+ {
+ return true;
+ }
+}
diff --git a/Classes/Maintenance/MetaDataRepair.php b/Classes/Maintenance/MetaDataRepair.php
new file mode 100644
index 0000000..98ca2f9
--- /dev/null
+++ b/Classes/Maintenance/MetaDataRepair.php
@@ -0,0 +1,208 @@
+.globalScope` leaves values behind that no longer match. Such
+ * values are never returned by {@see MetaDataManager} (reads only ever look up the scope a property is
+ * configured for), so this is a matter of hygiene rather than of correctness.
+ */
+final readonly class MetaDataRepair
+{
+ public function __construct(
+ private MetaDataManager $metaDataManager,
+ private DimensionSpacePointProvider $dimensionSpacePointProvider,
+ private MetaDataStorage $storage,
+ ) {
+ }
+
+ /**
+ * Whether the configured storage allows its values to be inspected and removed at all
+ */
+ public function isSupported(): bool
+ {
+ return $this->storage instanceof MetaDataStorageMaintenance;
+ }
+
+ /**
+ * Whether any content dimension is configured.
+ *
+ * If none is, every stored value for a dimension looks obsolete – which is exactly what a broken or
+ * half-loaded dimension configuration looks like, so pruning must be refused in that case.
+ */
+ public function hasConfiguredDimensions(): bool
+ {
+ foreach ($this->dimensionSpacePointProvider->getDimensionSpacePoints() as $dimensionSpacePoint) {
+ if ($dimensionSpacePoint->coordinates !== []) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @return list
+ */
+ public function analyze(): array
+ {
+ $propertyDefinitions = $this->metaDataManager->getPropertyDefinitions();
+ $validDimensionHashes = [];
+ foreach ($this->dimensionSpacePointProvider->getDimensionSpacePoints() as $dimensionSpacePoint) {
+ $validDimensionHashes[$dimensionSpacePoint->hash] = true;
+ }
+ $defaultChainHashes = $this->defaultChainHashes();
+ $defaultDimensionHash = $defaultChainHashes[0] ?? null;
+
+ $actions = [];
+ foreach ($this->groupedStoredValues() as $storedValues) {
+ $propertyName = $storedValues[0]->propertyName;
+ if (!$propertyDefinitions->include($propertyName)) {
+ foreach ($storedValues as $storedValue) {
+ $actions[] = new MetaDataRepairAction(MetaDataRepairActionType::deleteUndefinedProperty, $storedValue);
+ }
+ continue;
+ }
+ $globalValues = array_values(array_filter($storedValues, static fn (MetaDataStoredValue $v) => $v->global));
+ $dimensionedValues = array_values(array_filter($storedValues, static fn (MetaDataStoredValue $v) => !$v->global));
+
+ if ($propertyDefinitions->get($propertyName)->globalScope) {
+ if ($dimensionedValues === []) {
+ continue;
+ }
+ // Only promote if there is no shared value yet – an existing one is what reads return, so it wins
+ if ($globalValues === []) {
+ $actions[] = new MetaDataRepairAction(
+ MetaDataRepairActionType::promoteToGlobalScope,
+ $this->pickWinner($dimensionedValues, $defaultChainHashes),
+ );
+ }
+ foreach ($dimensionedValues as $storedValue) {
+ $actions[] = new MetaDataRepairAction(MetaDataRepairActionType::deleteWrongScope, $storedValue);
+ }
+ continue;
+ }
+
+ $hasDefaultDimensionValue = false;
+ foreach ($dimensionedValues as $storedValue) {
+ if ($storedValue->dimensionHash === $defaultDimensionHash) {
+ $hasDefaultDimensionValue = true;
+ }
+ if (!array_key_exists($storedValue->dimensionHash, $validDimensionHashes)) {
+ $actions[] = new MetaDataRepairAction(MetaDataRepairActionType::deleteObsoleteDimension, $storedValue);
+ }
+ }
+ foreach ($globalValues as $storedValue) {
+ // Only promote if the default dimension has no value yet, so live data is never overwritten
+ if (!$hasDefaultDimensionValue) {
+ $actions[] = new MetaDataRepairAction(MetaDataRepairActionType::promoteToDefaultDimension, $storedValue);
+ }
+ $actions[] = new MetaDataRepairAction(MetaDataRepairActionType::deleteWrongScope, $storedValue);
+ }
+ }
+ return $actions;
+ }
+
+ /**
+ * Carries out the given actions. Promotions are done before deletions, because a value that is
+ * promoted is usually stored in a row that is deleted afterwards.
+ *
+ * @param list $actions
+ * @return int the number of stored values that were removed
+ */
+ public function apply(array $actions, bool $prune = false): int
+ {
+ if (!$this->storage instanceof MetaDataStorageMaintenance) {
+ throw new RuntimeException(sprintf('The configured metadata storage %s does not support repairing', $this->storage::class), 1776280001);
+ }
+ $actions = array_values(array_filter($actions, static fn (MetaDataRepairAction $action) => $prune || !$action->type->requiresPrune()));
+
+ foreach ($actions as $action) {
+ match ($action->type) {
+ MetaDataRepairActionType::promoteToGlobalScope,
+ MetaDataRepairActionType::promoteToDefaultDimension => $this->metaDataManager->setMetaDataPropertyValue(
+ $action->storedValue->assetReference,
+ $action->storedValue->propertyName,
+ $action->storedValue->value,
+ ),
+ default => null,
+ };
+ }
+
+ $deletions = array_values(array_map(
+ static fn (MetaDataRepairAction $action) => $action->storedValue,
+ array_filter($actions, static fn (MetaDataRepairAction $action) => $action->type->isDeletion()),
+ ));
+ if ($deletions === []) {
+ return 0;
+ }
+ return $this->storage->deleteStoredValues(...$deletions);
+ }
+
+ // -----------------------
+
+ /**
+ * All stored values grouped per asset and property
+ *
+ * @return iterable>
+ */
+ private function groupedStoredValues(): iterable
+ {
+ assert($this->storage instanceof MetaDataStorageMaintenance);
+ $groups = [];
+ foreach ($this->storage->findAllStoredValues() as $storedValue) {
+ $key = implode("\0", [
+ $storedValue->assetReference->assetSourceId,
+ $storedValue->assetReference->assetId,
+ $storedValue->propertyName->value,
+ ]);
+ $groups[$key][] = $storedValue;
+ }
+ return $groups;
+ }
+
+ /**
+ * The value to keep when consolidating several localized values into a single shared one: the one
+ * the default dimension resolves to, or – if the value only exists in unrelated dimensions – the
+ * first in a stable order.
+ *
+ * @param non-empty-list $storedValues
+ * @param list $defaultChainHashes
+ */
+ private function pickWinner(array $storedValues, array $defaultChainHashes): MetaDataStoredValue
+ {
+ foreach ($defaultChainHashes as $dimensionHash) {
+ foreach ($storedValues as $storedValue) {
+ if ($storedValue->dimensionHash === $dimensionHash) {
+ return $storedValue;
+ }
+ }
+ }
+ usort($storedValues, static fn (MetaDataStoredValue $a, MetaDataStoredValue $b) => $a->dimensionHash <=> $b->dimensionHash);
+ return $storedValues[0];
+ }
+
+ /**
+ * @return list
+ */
+ private function defaultChainHashes(): array
+ {
+ $chain = $this->dimensionSpacePointProvider->getDimensionSpacePointChain(
+ $this->dimensionSpacePointProvider->getDefaultDimensionSpacePoint()
+ );
+ return $chain->map(static fn (MetaDataDimensionSpacePoint $dimensionSpacePoint) => $dimensionSpacePoint->hash);
+ }
+}
diff --git a/Classes/Maintenance/MetaDataRepairAction.php b/Classes/Maintenance/MetaDataRepairAction.php
new file mode 100644
index 0000000..d614b1e
--- /dev/null
+++ b/Classes/Maintenance/MetaDataRepairAction.php
@@ -0,0 +1,19 @@
+defaultContextVariables === null) {
- $this->defaultContextVariables = EelUtility::getDefaultContextVariables($this->defaultEelContext);
- }
- }
-
- /**
- * @param Asset $asset
- * @param MetaDataCollection $metaDataCollection
- */
- public function mapMetaData(Asset $asset, MetaDataCollection $metaDataCollection)
- {
- if ($asset->getResource()->isDeleted()) {
- return;
- }
-
- $contextVariables = array_merge($this->defaultContextVariables, $metaDataCollection->toArray());
-
- if (isset($this->metaDataMappingConfiguration['title'])) {
- $asset->setTitle(substr((string)EelUtility::evaluateEelExpression($this->metaDataMappingConfiguration['title'], $this->eelEvaluator, $contextVariables), 0, 255));
- }
-
- if (isset($this->metaDataMappingConfiguration['caption'])) {
- $asset->setCaption((string)EelUtility::evaluateEelExpression($this->metaDataMappingConfiguration['caption'], $this->eelEvaluator, $contextVariables));
- }
-
- if (isset($this->metaDataMappingConfiguration['copyrightNotice']) && method_exists($asset, 'setCopyrightNotice')) {
- $asset->setCopyrightNotice((string)EelUtility::evaluateEelExpression($this->metaDataMappingConfiguration['copyrightNotice'], $this->eelEvaluator, $contextVariables));
- }
-
- if (isset($this->metaDataMappingConfiguration['tags'])) {
- $tagLabels = EelUtility::evaluateEelExpression($this->metaDataMappingConfiguration['tags'], $this->eelEvaluator, $contextVariables);
- $tagLabels = array_unique($tagLabels);
-
- $tags = new ArrayCollection();
- foreach ($tagLabels as $tagLabel) {
- if (trim($tagLabel) !== '') {
- $tags->add($this->getOrCreateTag(trim($tagLabel)));
- }
- }
- $asset->setTags($tags);
- }
-
- if (isset($this->metaDataMappingConfiguration['collections'])) {
- $collectionTitles = EelUtility::evaluateEelExpression($this->metaDataMappingConfiguration['collections'], $this->eelEvaluator, $contextVariables);
- $collectionTitles = array_unique($collectionTitles);
-
- $collections = new ArrayCollection();
- foreach ($collectionTitles as $collectionTitle) {
- if (trim($collectionTitle) !== '') {
- $collections->add($this->getOrCreateCollection(trim($collectionTitle)));
- }
- }
- $asset->setAssetCollections($collections);
- }
-
- if (!$this->persistenceManager->isNewObject($asset)) {
- $this->assetRepository->update($asset);
- }
- }
-
- /**
- * @param string $label
- *
- * @return Tag
- */
- protected function getOrCreateTag($label)
- {
- if (isset($this->tagFirstLevelCache[$label])) {
- return $this->tagFirstLevelCache[$label];
- }
-
- $tag = $this->tagRepository->findOneByLabel($label);
-
- if ($tag === null) {
- $tag = new Tag($label);
- $this->tagRepository->add($tag);
- }
-
- $this->tagFirstLevelCache[$label] = $tag;
-
- return $tag;
- }
-
- /**
- * @param string $title
- *
- * @return AssetCollection
- */
- protected function getOrCreateCollection($title)
- {
- if (isset($this->collectionFirstLevelCache[$title])) {
- return $this->collectionFirstLevelCache[$title];
- }
-
- $collection = $this->collectionRepository->findOneByTitle($title);
-
- if ($collection === null) {
- $collection = new AssetCollection($title);
- $this->collectionRepository->add($collection);
- }
-
- $this->collectionFirstLevelCache[$title] = $collection;
-
- return $collection;
- }
-}
diff --git a/Classes/Mapper/MetaDataMapperInterface.php b/Classes/Mapper/MetaDataMapperInterface.php
deleted file mode 100644
index 687ec22..0000000
--- a/Classes/Mapper/MetaDataMapperInterface.php
+++ /dev/null
@@ -1,26 +0,0 @@
-propertyDefinitions;
+ }
+
+ public function getDimensionSpacePointConfiguration(): MetaDataDimensionSpacePoints
+ {
+ return $this->dimensionSpacePointProvider->getDimensionSpacePoints();
+ }
+
+ /**
+ * Sets the value of a single metadata property.
+ *
+ * The value is coerced to the type the property is defined for, so that callers which only ever
+ * have strings – the command line, form input, Fusion – do not have to cast. A value that cannot be
+ * interpreted as that type is rejected rather than silently turned into a wrong one,
+ * see {@see MetaDataPropertyType::coerceForStorage()}.
+ */
+ public function setMetaDataPropertyValue(
+ MetaDataAssetReference $assetReference,
+ MetaDataPropertyName|string $propertyName,
+ string|int|bool $value,
+ ?MetaDataDimensionSpacePoint $dimensionSpacePoint = null,
+ ): void {
+ $propertyDefinition = $this->propertyDefinition($propertyName);
+
+ // TODO: ACL
+ $this->storage->setMetaDataPropertyValue(
+ $assetReference,
+ $propertyDefinition->name,
+ $propertyDefinition->type->coerceForStorage($value),
+ $this->writeScope($propertyDefinition, $dimensionSpacePoint),
+ );
+ }
+
+ public function unsetMetaDataPropertyValue(
+ MetaDataAssetReference $assetReference,
+ MetaDataPropertyName|string $propertyName,
+ ?MetaDataDimensionSpacePoint $dimensionSpacePoint = null,
+ ): void {
+ $propertyDefinition = $this->propertyDefinition($propertyName);
+
+ // TODO: ACL
+ $this->storage->unsetMetaDataPropertyValue(
+ $assetReference,
+ $propertyDefinition->name,
+ $this->writeScope($propertyDefinition, $dimensionSpacePoint),
+ );
+ }
+
+ /**
+ * The value of a single metadata property, as seen from the given dimension space point.
+ *
+ * The result carries the own and the inherited value side by side, see {@see MetaDataPropertyValue}.
+ */
+ public function getMetaDataPropertyValue(
+ MetaDataAssetReference $assetReference,
+ MetaDataPropertyName|string $propertyName,
+ ?MetaDataDimensionSpacePoint $dimensionSpacePoint = null,
+ ): MetaDataPropertyValue {
+ return $this->resolvePropertyValue(
+ $assetReference,
+ $this->propertyDefinition($propertyName),
+ $dimensionSpacePoint,
+ );
+ }
+
+ /**
+ * The values of all defined metadata properties, as seen from the given dimension space point.
+ *
+ * Every defined property is contained in the result, properties without any stored value with an
+ * empty {@see MetaDataPropertyValue}.
+ */
+ public function getMetaDataPropertyValues(
+ MetaDataAssetReference $assetReference,
+ ?MetaDataDimensionSpacePoint $dimensionSpacePoint = null,
+ ): MetaDataPropertyValues {
+ $propertyValues = MetaDataPropertyValues::createEmpty();
+ foreach ($this->propertyDefinitions as $propertyDefinition) {
+ $propertyValues = $propertyValues->with(
+ $propertyDefinition->name,
+ $this->resolvePropertyValue($assetReference, $propertyDefinition, $dimensionSpacePoint),
+ );
+ }
+ return $propertyValues;
+ }
-/**
- * @Flow\Scope("singleton")
- */
-class MetaDataManager
-{
/**
- * @Flow\Inject
- * @var AssetModelMetaDataMapper
+ * References of all assets that have a matching metadata value, as seen from one dimension space
+ * point.
+ *
+ * A value counts only if it is the one that {@see self::getMetaDataPropertyValue()} would return for
+ * the filter's dimension space point, so the search agrees with what an editor working in that
+ * dimension sees: an asset whose caption is inherited from a fallback dimension is found, one whose
+ * inherited caption is overridden by a non matching value of its own is not.
+ *
+ * Properties with a global scope are matched on their shared value regardless of the dimension space
+ * point, just like they are read regardless of it.
+ *
+ * The result is lazily streamed and each asset is contained at most once.
+ *
+ * NOTE: This returns {@see MetaDataAssetReference}s – the identity of an asset within its asset
+ * source – not `Asset` objects. This package never touches the asset model.
+ *
+ * @return iterable
*/
- protected $assetModelMetaDataMapper;
+ public function findAssets(MetaDataAssetFilter $filter): iterable
+ {
+ $localizedPropertyNames = [];
+ $globalScopePropertyNames = [];
+ foreach ($this->filteredPropertyDefinitions($filter->propertyNames) as $propertyDefinition) {
+ if ($propertyDefinition->globalScope) {
+ $globalScopePropertyNames[] = $propertyDefinition->name;
+ } else {
+ $localizedPropertyNames[] = $propertyDefinition->name;
+ }
+ }
+
+ return $this->storage->findAssets(
+ $filter->assetSourceId,
+ $filter->searchTerm,
+ MetaDataPropertyNames::create(...$localizedPropertyNames),
+ $this->dimensionSpacePointProvider->getDimensionSpacePointChain(
+ $this->validateDimensionSpacePoint($filter->dimensionSpacePoint)
+ ),
+ MetaDataPropertyNames::create(...$globalScopePropertyNames),
+ );
+ }
+
+ // -----------------------
/**
- * @param Asset $asset
- * @param MetaDataCollection $metaDataCollection
+ * The definitions of the given property names, or all of them if no names are given.
+ *
+ * @return iterable
*/
- public function updateMetaDataForAsset(Asset $asset, MetaDataCollection $metaDataCollection)
+ private function filteredPropertyDefinitions(?MetaDataPropertyNames $propertyNames): iterable
{
- $this->assetModelMetaDataMapper->mapMetaData($asset, $metaDataCollection);
- $this->emitMetaDataCollectionUpdated($asset, $metaDataCollection);
+ if ($propertyNames === null) {
+ return $this->propertyDefinitions;
+ }
+ return array_map($this->propertyDefinition(...), iterator_to_array($propertyNames));
+ }
+
+ /**
+ * Resolves the own and the inherited value of a single property with one storage lookup
+ */
+ private function resolvePropertyValue(
+ MetaDataAssetReference $assetReference,
+ MetaDataPropertyDefinition $propertyDefinition,
+ ?MetaDataDimensionSpacePoint $dimensionSpacePoint,
+ ): MetaDataPropertyValue {
+ // TODO: ACL
+ if ($propertyDefinition->globalScope) {
+ return $this->resolveGlobalPropertyValue($assetReference, $propertyDefinition);
+ }
+
+ $candidates = $this->dimensionSpacePointProvider->getDimensionSpacePointChain(
+ $this->validateDimensionSpacePoint($dimensionSpacePoint)
+ );
+ $storedValues = $this->storage->getMetaDataPropertyValues($assetReference, $propertyDefinition->name, $candidates);
+ if ($storedValues === []) {
+ return MetaDataPropertyValue::createEmpty();
+ }
+
+ $ownValue = null;
+ foreach ($candidates as $index => $candidate) {
+ if (!array_key_exists($candidate->hash, $storedValues)) {
+ continue;
+ }
+ $value = $propertyDefinition->type->fromStoredValue($storedValues[$candidate->hash]);
+ // A value that cannot be interpreted as the configured type is treated like an absent one,
+ // so that it neither surfaces nor shadows a fallback that is still readable
+ if ($value === null) {
+ continue;
+ }
+ // The first candidate is the dimension space point that was asked for, all others are fallbacks
+ if ($index === 0) {
+ $ownValue = $value;
+ continue;
+ }
+ return MetaDataPropertyValue::create($ownValue, $value, $candidate);
+ }
+ return MetaDataPropertyValue::create($ownValue);
+ }
+
+ /**
+ * A value of a global scope is shared by all dimensions, so it is never inherited
+ */
+ private function resolveGlobalPropertyValue(
+ MetaDataAssetReference $assetReference,
+ MetaDataPropertyDefinition $propertyDefinition,
+ ): MetaDataPropertyValue {
+ $storedValues = $this->storage->getMetaDataPropertyValues(
+ $assetReference,
+ $propertyDefinition->name,
+ MetaDataGlobalScope::create(),
+ );
+ if ($storedValues === []) {
+ return MetaDataPropertyValue::createEmpty();
+ }
+ return MetaDataPropertyValue::create($propertyDefinition->type->fromStoredValue(reset($storedValues)));
}
/**
- * @Flow\Signal
+ * The scope a value of the given property is written to.
*
- * @param Asset $asset
- * @param MetaDataCollection $metaDataCollection
+ * For properties of a global scope the dimension space point is ignored on purpose: callers pass the
+ * dimension they are currently working in without having to know which properties are localized.
*/
- public function emitMetaDataCollectionUpdated(Asset $asset, MetaDataCollection $metaDataCollection)
+ private function writeScope(
+ MetaDataPropertyDefinition $propertyDefinition,
+ ?MetaDataDimensionSpacePoint $dimensionSpacePoint,
+ ): MetaDataDimensionSpacePoint|MetaDataGlobalScope {
+ if ($propertyDefinition->globalScope) {
+ return MetaDataGlobalScope::create();
+ }
+ return $this->validateDimensionSpacePoint($dimensionSpacePoint);
+ }
+
+ private function propertyDefinition(MetaDataPropertyName|string $propertyName): MetaDataPropertyDefinition
{
+ if (is_string($propertyName)) {
+ $propertyName = MetaDataPropertyName::fromString($propertyName);
+ }
+ if (!$this->propertyDefinitions->include($propertyName)) {
+ throw new InvalidArgumentException(sprintf('Metadata property "%s" is not defined', $propertyName), 1776278047);
+ }
+ return $this->propertyDefinitions->get($propertyName);
}
+
+ private function validateDimensionSpacePoint(?MetaDataDimensionSpacePoint $dimensionSpacePoint): MetaDataDimensionSpacePoint
+ {
+ if ($dimensionSpacePoint === null) {
+ return $this->dimensionSpacePointProvider->getDefaultDimensionSpacePoint();
+ }
+
+ if (!$this->dimensionSpacePointProvider->isDimensionSpacePointValid($dimensionSpacePoint)) {
+ throw new InvalidArgumentException(sprintf('Dimension Space Point "%s" is not configured', $dimensionSpacePoint), 1776279083);
+ }
+ return $dimensionSpacePoint;
+ }
+
}
diff --git a/Classes/MetaDataManagerFactory.php b/Classes/MetaDataManagerFactory.php
new file mode 100644
index 0000000..e96c0bb
--- /dev/null
+++ b/Classes/MetaDataManagerFactory.php
@@ -0,0 +1,29 @@
+dimensionSpacePointProvider,
+ $this->assetMetaDataConfigurationProvider->getPropertyConfiguration(),
+ $this->metaDataStorageProvider,
+ );
+ }
+}
diff --git a/Classes/Storage/MetaDataStorage.php b/Classes/Storage/MetaDataStorage.php
new file mode 100644
index 0000000..25e8655
--- /dev/null
+++ b/Classes/Storage/MetaDataStorage.php
@@ -0,0 +1,77 @@
+
+ */
+ public function getMetaDataPropertyValues(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoints|MetaDataGlobalScope $scope): array;
+
+ /**
+ * References of all assets that have a matching value for at least one of the given properties.
+ *
+ * Unlike {@see self::getMetaDataPropertyValues()} the order of $dimensionSpacePointChain *is*
+ * meaningful: it runs from the most to the least specific dimension space point, and only the
+ * closest stored value of a property counts. A value stored for a dimension space point further
+ * down the chain must be ignored if the same property also has a value further up – it is shadowed
+ * and never surfaces for the dimension that was asked for. This is not a resolution rule that
+ * implementations get to choose; it is the ranking they are handed.
+ *
+ * Localized and global scope properties are given separately because their values live in different
+ * scopes: the ones named in $localizedPropertyNames are looked up along the chain, the ones named in
+ * $globalScopePropertyNames in the global scope, which no dimension space point applies to. Either
+ * set may be empty. Values stored in the respective other scope – e.g. left behind after a change of
+ * {@see MetaDataPropertyDefinition::$globalScope} – must not be matched.
+ *
+ * The search term matches if it is contained anywhere in a value, case insensitively. NULL matches
+ * every stored value, i.e. every asset that has any value for the given properties at all. An asset
+ * that matches several times is returned once.
+ *
+ * Implementations should stream rather than materialize the whole result, as it can cover every
+ * asset that has metadata.
+ *
+ * @param string|null $assetSourceId NULL matches assets of every asset source
+ * @param string|null $searchTerm NULL matches every stored value
+ * @return iterable ordered by asset source id, then asset id
+ */
+ public function findAssets(
+ ?string $assetSourceId,
+ ?string $searchTerm,
+ MetaDataPropertyNames $localizedPropertyNames,
+ MetaDataDimensionSpacePoints $dimensionSpacePointChain,
+ MetaDataPropertyNames $globalScopePropertyNames,
+ ): iterable;
+
+}
diff --git a/Classes/Storage/MetaDataStorageMaintenance.php b/Classes/Storage/MetaDataStorageMaintenance.php
new file mode 100644
index 0000000..88fcb39
--- /dev/null
+++ b/Classes/Storage/MetaDataStorageMaintenance.php
@@ -0,0 +1,31 @@
+
+ */
+ public function findAllStoredValues(): iterable;
+
+ /**
+ * @return int the number of values that were removed
+ */
+ public function deleteStoredValues(MetaDataStoredValue ...$storedValues): int;
+}
diff --git a/Classes/Storage/MetaDataStorageProviderDbalAdapter.php b/Classes/Storage/MetaDataStorageProviderDbalAdapter.php
new file mode 100644
index 0000000..0840349
--- /dev/null
+++ b/Classes/Storage/MetaDataStorageProviderDbalAdapter.php
@@ -0,0 +1,259 @@
+connection->executeStatement(
+ $statement,
+ [
+ 'assetSourceId' => $assetReference->assetSourceId,
+ 'assetId' => $assetReference->assetId,
+ 'propertyName' => $propertyName->value,
+ 'propertyValue' => $propertyValue,
+ 'dimensionHash' => self::dimensionHash($scope),
+ ]
+ );
+ }
+
+ public function unsetMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void
+ {
+ $this->connection->delete(self::TABLE_NAME, [
+ 'asset_source_id' => $assetReference->assetSourceId,
+ 'asset_id' => $assetReference->assetId,
+ 'property_name' => $propertyName->value,
+ 'dimension_hash' => self::dimensionHash($scope),
+ ]);
+ }
+
+ public function getMetaDataPropertyValues(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoints|MetaDataGlobalScope $scope): array
+ {
+ $dimensionHashes = self::dimensionHashes($scope);
+ if ($dimensionHashes === []) {
+ return [];
+ }
+ $query = $this->connection->createQueryBuilder();
+ $query->select('dimension_hash', 'property_value')
+ ->from(self::TABLE_NAME)
+ ->where(
+ $query->expr()->and(
+ $query->expr()->eq('asset_source_id', ':assetSourceId'),
+ $query->expr()->eq('asset_id', ':assetId'),
+ $query->expr()->eq('property_name', ':propertyName'),
+ $query->expr()->in('dimension_hash', ':dimensionHashes'),
+ )
+ )
+ // NOTE: No ordering – which of the values wins is a domain decision that is made by the MetaDataManager
+ ->setParameters([
+ 'assetSourceId' => $assetReference->assetSourceId,
+ 'assetId' => $assetReference->assetId,
+ 'propertyName' => $propertyName->value,
+ 'dimensionHashes' => $dimensionHashes,
+ ], [
+ 'dimensionHashes' => ArrayParameterType::STRING,
+ ]);
+
+ $values = [];
+ foreach ($query->executeQuery()->iterateAssociative() as $row) {
+ $values[$row['dimension_hash']] = $row['property_value'];
+ }
+ return $values;
+ }
+
+ public function findAssets(
+ ?string $assetSourceId,
+ ?string $searchTerm,
+ MetaDataPropertyNames $localizedPropertyNames,
+ MetaDataDimensionSpacePoints $dimensionSpacePointChain,
+ MetaDataPropertyNames $globalScopePropertyNames,
+ ): iterable {
+ $parameters = [];
+ $scopeConditions = [];
+
+ $chainHashes = $dimensionSpacePointChain->map(static fn (MetaDataDimensionSpacePoint $dimensionSpacePoint) => $dimensionSpacePoint->hash);
+ if (!$localizedPropertyNames->isEmpty() && $chainHashes !== []) {
+ $chainPlaceholders = self::bindList($parameters, 'dsp', $chainHashes);
+ $namePlaceholders = self::bindList($parameters, 'localizedProperty', $localizedPropertyNames->map(static fn (MetaDataPropertyName $propertyName) => $propertyName->value));
+ // The value must be the closest one along the chain – a value further down is shadowed and
+ // never surfaces for the dimension space point that was asked for.
+ // NOTE: the identity columns are nullable, so the correlation uses the NULL safe `<=>`
+ $scopeConditions[] = sprintf(<<<'MYSQL'
+ (
+ v.property_name IN (%1$s) AND v.dimension_hash IN (%2$s) AND NOT EXISTS (
+ SELECT 1 FROM %3$s v2
+ WHERE v2.asset_source_id <=> v.asset_source_id
+ AND v2.asset_id <=> v.asset_id
+ AND v2.property_name = v.property_name
+ AND v2.dimension_hash IN (%2$s)
+ AND FIELD(v2.dimension_hash, %2$s) < FIELD(v.dimension_hash, %2$s)
+ )
+ )
+ MYSQL, $namePlaceholders, $chainPlaceholders, self::TABLE_NAME);
+ }
+
+ if (!$globalScopePropertyNames->isEmpty()) {
+ $namePlaceholders = self::bindList($parameters, 'globalProperty', $globalScopePropertyNames->map(static fn (MetaDataPropertyName $propertyName) => $propertyName->value));
+ $parameters['globalDimensionHash'] = self::GLOBAL_DIMENSION_HASH;
+ $scopeConditions[] = sprintf('(v.property_name IN (%s) AND v.dimension_hash = :globalDimensionHash)', $namePlaceholders);
+ }
+
+ if ($scopeConditions === []) {
+ return [];
+ }
+
+ $conditions = [sprintf('(%s)', implode(' OR ', $scopeConditions))];
+ if ($assetSourceId !== null) {
+ $conditions[] = 'v.asset_source_id = :assetSourceId';
+ $parameters['assetSourceId'] = $assetSourceId;
+ }
+ if ($searchTerm !== null) {
+ $conditions[] = "v.property_value LIKE :searchTerm ESCAPE '\\\\'";
+ $parameters['searchTerm'] = '%' . self::escapeLikeWildcards($searchTerm) . '%';
+ }
+
+ $statement = sprintf(<<<'MYSQL'
+ SELECT DISTINCT v.asset_source_id, v.asset_id
+ FROM %s v
+ WHERE %s
+ ORDER BY v.asset_source_id, v.asset_id
+ MYSQL, self::TABLE_NAME, implode(' AND ', $conditions));
+
+ return $this->streamAssetReferences($statement, $parameters);
+ }
+
+ public function findAllStoredValues(): iterable
+ {
+ $query = $this->connection->createQueryBuilder();
+ $query->select('asset_source_id', 'asset_id', 'property_name', 'property_value', 'dimension_hash')
+ ->from(self::TABLE_NAME);
+ foreach ($query->executeQuery()->iterateAssociative() as $row) {
+ /** @var string $assetSourceId */
+ $assetSourceId = $row['asset_source_id'];
+ /** @var string $assetId */
+ $assetId = $row['asset_id'];
+ /** @var string $propertyName */
+ $propertyName = $row['property_name'];
+ /** @var string $dimensionHash */
+ $dimensionHash = $row['dimension_hash'];
+ /** @var string $propertyValue */
+ $propertyValue = $row['property_value'];
+ yield new MetaDataStoredValue(
+ MetaDataAssetReference::create($assetSourceId, $assetId),
+ MetaDataPropertyName::fromString($propertyName),
+ $dimensionHash,
+ $dimensionHash === self::GLOBAL_DIMENSION_HASH,
+ $propertyValue,
+ );
+ }
+ }
+
+ public function deleteStoredValues(MetaDataStoredValue ...$storedValues): int
+ {
+ $deleted = 0;
+ foreach ($storedValues as $storedValue) {
+ $deleted += $this->connection->delete(self::TABLE_NAME, [
+ 'asset_source_id' => $storedValue->assetReference->assetSourceId,
+ 'asset_id' => $storedValue->assetReference->assetId,
+ 'property_name' => $storedValue->propertyName->value,
+ 'dimension_hash' => $storedValue->dimensionHash,
+ ]);
+ }
+ return $deleted;
+ }
+
+ // -----------------------
+
+ /**
+ * Binds the given values as individually named parameters and returns the corresponding placeholder
+ * list for an `IN (...)` or `FIELD(...)` expression.
+ *
+ * The placeholders are named rather than expanded from an array parameter, because the dimension
+ * hashes occur multiple times within the same statement.
+ *
+ * @param array $parameters mutated in place
+ * @param list $values
+ */
+ private static function bindList(array &$parameters, string $prefix, array $values): string
+ {
+ $placeholders = [];
+ foreach ($values as $index => $value) {
+ $parameterName = $prefix . $index;
+ $parameters[$parameterName] = $value;
+ $placeholders[] = ':' . $parameterName;
+ }
+ return implode(', ', $placeholders);
+ }
+
+ /**
+ * Escapes the characters that are wildcards within a LIKE pattern, so that a search for "50%" does
+ * not match every value
+ */
+ private static function escapeLikeWildcards(string $searchTerm): string
+ {
+ return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $searchTerm);
+ }
+
+ /**
+ * @param array $parameters
+ * @return iterable
+ */
+ private function streamAssetReferences(string $statement, array $parameters): iterable
+ {
+ foreach ($this->connection->executeQuery($statement, $parameters)->iterateAssociative() as $row) {
+ /** @var string $assetSourceId */
+ $assetSourceId = $row['asset_source_id'];
+ /** @var string $assetId */
+ $assetId = $row['asset_id'];
+ yield MetaDataAssetReference::create($assetSourceId, $assetId);
+ }
+ }
+
+ private static function dimensionHash(MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): string
+ {
+ return $scope instanceof MetaDataGlobalScope ? self::GLOBAL_DIMENSION_HASH : $scope->hash;
+ }
+
+ /**
+ * @return list
+ */
+ private static function dimensionHashes(MetaDataDimensionSpacePoints|MetaDataGlobalScope $scope): array
+ {
+ if ($scope instanceof MetaDataGlobalScope) {
+ return [self::GLOBAL_DIMENSION_HASH];
+ }
+ return $scope->map(static fn (MetaDataDimensionSpacePoint $dimensionSpacePoint) => $dimensionSpacePoint->hash);
+ }
+}
diff --git a/Classes/Storage/MetaDataStoredValue.php b/Classes/Storage/MetaDataStoredValue.php
new file mode 100644
index 0000000..23f527b
--- /dev/null
+++ b/Classes/Storage/MetaDataStoredValue.php
@@ -0,0 +1,32 @@
+abortIf(!$this->connection->getDatabasePlatform() instanceof MySQLPlatform, 'Migration can only be executed safely on MySQL/MariaDB.');
+
+ $this->addSql('CREATE TABLE neos_metadata_value (
+ `asset_source_id` VARCHAR(255) DEFAULT NULL,
+ `asset_id` VARCHAR(40) DEFAULT NULL,
+ `property_name` VARCHAR(40) NOT NULL,
+ `property_value` VARCHAR(250) NOT NULL,
+ `dimension_hash` VARCHAR(250) NOT NULL,
+ UNIQUE INDEX idx_unique (`asset_source_id`, `asset_id`, `property_name`, `dimension_hash`)
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
+ $this->addSql('ALTER TABLE neos_metadata_value ADD CONSTRAINT fk_asset FOREIGN KEY (asset_id) REFERENCES neos_media_domain_model_asset (persistence_object_identifier) ON DELETE CASCADE');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->abortIf(!$this->connection->getDatabasePlatform() instanceof MySQLPlatform, 'Migration can only be executed safely on MySQL/MariaDB.');
+
+ $this->addSql('ALTER TABLE neos_metadata_value DROP FOREIGN KEY fk_asset');
+ $this->addSql('DROP TABLE neos_metadata_value');
+ }
+}
diff --git a/Readme.md b/Readme.md
index 960dd12..8662982 100644
--- a/Readme.md
+++ b/Readme.md
@@ -4,41 +4,352 @@
# Neos.MetaData Package
-This package provides data types and interfaces to handle meta data for assets in Neos (or Flow).
+This package allows extensible, dimension-aware meta data properties to be attached to assets in Neos
+(or Flow).
+
+Meta data properties are *declared in Settings*, *stored outside the asset* (in a dedicated database
+table) and *resolved per dimension space point* with fallbacks along the configured content dimension
+presets. This means the same asset can have a different caption per language or country, without
+changing the `Asset` model itself. Properties that must not be localized – a copyright notice, say –
+can be declared to have a *global scope*, giving them a single value shared by all dimensions.
+
+## Requirements
+
+* PHP 8.4 or newer
+* `neos/media` 8.3, 8.4 or 9.0
+* MySQL or MariaDB (the shipped Doctrine migration and the DBAL storage adapter are MySQL-specific)
## Installation
Install using composer:
- composer require neos/metadata
+ composer require neos/metadata
-If you install a package that depends on this package, you should not need to require it manually,
-though. Some related packages are:
+Afterwards apply the Doctrine migrations to create the `neos_metadata_value` table:
-- [`neos/metadata-extractor`](https://github.com/neos/metadata-extractor): Provides CLI and realtime
- meta data extraction on assets
-- [`neos/metadata-contentrepositoryadapter`](https://github.com/neos/metadata-contentrepositoryadapter):
- Handles the mapping of meta data DTOs to the Neos Content Repository
+ ./flow doctrine:migrate
## Configuration
-The provided asset meta data mapper is configured with Eel expressions to determine the source of
-mapped data. Check the setup below `Neos.MetaData.metaDataMapping`.
+Meta data properties are declared below `Neos.MetaData.metaDataProperties`, keyed by property name:
+
+```yaml
+Neos:
+ MetaData:
+ metaDataProperties:
+ 'copyright':
+ type: string
+ globalScope: true
+ ui:
+ label: i18n
+ inspector:
+ editor: 'Neos.Neos/Inspector/Editors/TextAreaEditor'
+ editorOptions:
+ rows: 7
+```
+
+| Option | Description |
+|-------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `type` | `string` (default), `integer` or `boolean` |
+| `globalScope` | `true` = a single value shared by all dimensions, `false` (default) = one value per dimension space point |
+| `ui.label` | Label for the property. The literal value `i18n` looks up the translation id `properties.` in `Neos.MetaData:Main`; any other value is used verbatim |
+| `ui.inspector.editor` | Editor to use for this property in the Neos UI inspector |
+| `ui.inspector.editorOptions` | Editor specific options |
+
+The package ships with three properties out of the box: `copyright` (global scope), `altText` and
+`caption`.
+
+A property that is set to `null` is skipped, which allows to disable properties that are configured
+elsewhere:
+
+```yaml
+Neos:
+ MetaData:
+ metaDataProperties:
+ 'copyright': ~
+```
+
+Dimensions are *not* configured in this package. They are taken from the Content Repository content
+dimension presets (`Neos.ContentRepository.contentDimensions`) via
+`DimensionSpacePointProviderContentRepositoryAdapter`. If no content dimensions are configured, the
+only valid dimension space point is the empty one.
+
+Changing `globalScope` of a property that already has values stored leaves values behind that no longer
+match its scope. Those are never returned when reading, see [`assetmetadata:repair`](#command-line).
+
+### Property types
+
+Values are coerced to the `type` a property is declared with – on the way in, so that nothing but a
+value of that type is ever stored, and on the way out, so that a reader gets a value of that type back.
+`MetaDataPropertyValue::$value` therefore means what its `string|int|bool|null` signature says.
+
+Unambiguous conversions are applied, so that callers which only ever have strings – the command line,
+form input, Fusion – do not have to cast:
+
+| Type | Accepted | Stored as |
+|-----------|--------------------------------------------------------------------------------------------------|------------------|
+| `string` | anything | as provided |
+| `integer` | an `int`, an optionally signed decimal string like `"-42"`, or a boolean | decimal |
+| `boolean` | a `bool`, `"true"`/`"on"`/`"yes"`/`"1"` and `"false"`/`"off"`/`"no"`/`"0"` (any case), or `1`/`0` | `1` or `0` |
+
+Anything else is rejected with an `InvalidArgumentException` rather than silently turned into a wrong
+value – `"abc"` is not `0`. Surrounding whitespace is tolerated for `integer` and `boolean` but kept
+verbatim for `string`.
+
+Reading is deliberately more forgiving, because it meets values that were written before a property was
+given its current type: a stored value that cannot be interpreted reads as `NULL`, i.e. the property
+behaves as if it had no value for that dimension – and does not shadow a fallback that is still
+readable. Note that the search of `findAssets()` matches the *stored* representation, so a `boolean` is
+matched as `1`/`0` rather than as `true`/`false`.
## Usage
-The package does not in itself change the way metadata is handled. Instead it provides ways for
-other packages to interact with meta data of assets.
+### PHP API
+
+`Neos\MetaData\MetaDataManager` is the central entry point. An asset is addressed by a
+`MetaDataAssetReference` (asset source id + asset id), a dimension by a `MetaDataDimensionSpacePoint`
+(coordinates like `['language' => 'de']`). Wherever a dimension space point can be passed, `null` means
+the default one.
+
+```php
+use Neos\MetaData\Domain\Dto\MetaDataAssetReference;
+use Neos\MetaData\Domain\Dto\MetaDataDimensionSpacePoint;
+use Neos\MetaData\MetaDataManager;
+
+#[Flow\Inject]
+protected MetaDataManager $metaDataManager;
+
+$assetReference = MetaDataAssetReference::create($asset->assetSourceIdentifier, $asset->getIdentifier());
+$german = MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de']);
+
+$this->metaDataManager->setMetaDataPropertyValue($assetReference, 'caption', 'Eine Katze', $german);
+$values = $this->metaDataManager->getMetaDataPropertyValues($assetReference, $german);
+```
+
+| Method | Description |
+|-----------------------------------------|-----------------------------------------------------------------------------------------------|
+| `getPropertyDefinitions()` | All configured property definitions (name, type, scope, UI definition) |
+| `getDimensionSpacePointConfiguration()` | All dimension space points resulting from the configured dimension presets |
+| `setMetaDataPropertyValue()` | Sets a single property value |
+| `unsetMetaDataPropertyValue()` | Removes a single property value |
+| `getMetaDataPropertyValue()` | The value of one property, as a `MetaDataPropertyValue` |
+| `getMetaDataPropertyValues()` | The values of all defined properties, as `MetaDataPropertyValues` |
+| `findAssets()` | References of the assets matching a `MetaDataAssetFilter` |
+
+Unknown property names, dimension space points that are not allowed by the configured preset
+constraints and values that do not match the type a property is declared with lead to an
+`InvalidArgumentException`.
+
+### Reading values: own, inherited and effective
+
+There is a single read, because the three things one usually wants to know are three views of the same
+answer. `MetaDataPropertyValue` carries them side by side:
+
+| Field | Use case |
+|-------------------------|--------------------------------------------------------------------------------------------------|
+| `ownValue` | Editing. The value stored for *this* dimension, so an input field does not show a fallback value that the editor did not enter |
+| `inheritedValue`, `inheritedFrom` | The translation hint below that input field: what this dimension falls back to, and where it comes from |
+| `value` | Rendering to visitors: `ownValue ?? inheritedValue` |
+
+`hasOwnValue()` and `isInherited()` are convenience predicates on top of those.
+
+```php
+$value = $this->metaDataManager->getMetaDataPropertyValue($assetReference, 'caption', $german);
+
+$value->ownValue; // 'Eine Katze', or NULL if only a fallback exists
+$value->inheritedValue; // 'A cat'
+$value->inheritedFrom; // MetaDataDimensionSpacePoint for ['language' => 'en']
+$value->value; // 'Eine Katze'
+```
+
+For properties with a **global scope** the value is shared by all dimensions, so it is never inherited:
+`ownValue` is the shared value and `inheritedValue` is always `null`. The dimension space point that is
+passed in is ignored for such properties – callers can always pass the dimension they are working in
+without having to know which properties are localized.
+
+### Finding assets
+
+`findAssets()` returns the assets that have a matching metadata value. All criteria of a
+`MetaDataAssetFilter` are optional and are combined with AND:
+
+```php
+use Neos\MetaData\Domain\Dto\MetaDataAssetFilter;
+use Neos\MetaData\Domain\Dto\MetaDataPropertyNames;
+
+$filter = MetaDataAssetFilter::create(
+ searchTerm: 'cat',
+ dimensionSpacePoint: MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de']),
+ propertyNames: MetaDataPropertyNames::create('caption', 'altText'),
+);
+
+foreach ($this->metaDataManager->findAssets($filter) as $assetReference) {
+ $assetReference->assetSourceId;
+ $assetReference->assetId;
+}
+```
+
+| Criterion | Omitted means |
+|-----------------------|--------------------------------------------------------------------------------------------|
+| `assetSourceId` | assets of every asset source |
+| `dimensionSpacePoint` | the *default* dimension space point – as everywhere else in this package, **not** "any dimension" |
+| `searchTerm` | every asset that has a value for the filtered properties at all |
+| `propertyNames` | all defined properties |
+
+The search term matches if it is contained anywhere in a value, ignoring case and accents. `%` and `_`
+are matched literally rather than as wildcards. A term that is empty or consists of whitespace only is
+treated like an omitted one, so clearing a search field behaves like not having searched.
+
+A value only counts if it is the one `getMetaDataPropertyValue()` would return for the filter's
+dimension space point, so the search agrees with what an editor working in that dimension sees. Given
+an asset with the English caption `A cat`, searching for `cat` in German finds it as long as German
+inherits that caption – and stops finding it as soon as a German caption of its own is set. Properties
+with a global scope are matched on their shared value regardless of the dimension space point, just
+like they are read regardless of it.
+
+The result is lazily streamed, contains each asset at most once and is ordered by asset source id and
+asset id. It carries `MetaDataAssetReference`s – the identity of an asset within its asset source – not
+`Asset` objects; resolving those is up to the caller, this package never touches the asset model.
+Unknown property names and dimension space points that are not allowed by the configured preset
+constraints lead to an `InvalidArgumentException`, as they do everywhere else.
+
+### Fusion / Eel
+
+The Eel helper `AssetMetaData` is registered in the default Fusion context and returns the *effective*
+values:
+
+```
+caption = ${AssetMetaData.getMetaDataProperty(asset, 'caption', {language: 'de'})}
+allMetaData = ${AssetMetaData.getMetaData(asset, {language: 'de'})}
+```
+
+| Method | Description |
+|-----------------------------------------------------|-------------------------------------------------------------------------------------------|
+| `getMetaDataProperty(asset, propertyName, coordinates = [])` | The effective value of a single property, or `NULL` if it is not set |
+| `getMetaData(asset, coordinates = [])` | An array of all configured property names mapped to their effective values |
+
+Empty coordinates mean the default dimension.
+
+### Command line
+
+```bash
+# List all meta data properties of an asset, marking inherited values
+./flow assetmetadata:list --asset-id [--asset-source neos] [--dimension-space-point '{"language":"de"}']
+
+# Set a single property
+./flow assetmetadata:set --asset-id --property caption --value "A picture" [--asset-source neos] [--dimension-space-point '{"language":"de"}']
+
+# Remove a single property
+./flow assetmetadata:unset --asset-id --property caption [--asset-source neos] [--dimension-space-point '{"language":"de"}']
+```
+
+`--asset-source` defaults to `neos`, `--dimension-space-point` to the default dimension space point.
+For properties with a global scope the dimension space point is ignored.
+
+To move the caption and copyright notice already stored on existing `Asset` models into this package's
+storage:
+
+```bash
+./flow assetmetadatamigration:migrateexistingassetproperties
+```
+
+To find and fix values whose scope contradicts the current configuration – e.g. after changing
+`globalScope` of a property:
+
+```bash
+# Report only, nothing is changed
+./flow assetmetadata:repair
+
+# Apply the reported changes
+./flow assetmetadata:repair --force
+
+# Also remove values of dimensions and properties that are no longer configured
+./flow assetmetadata:repair --force --prune
+```
+
+When a property became global, the value the default dimension resolves to is kept as the shared one
+and the remaining ones are removed. When a property became localized, the shared value is stored for
+the default dimension space point. Existing values are never overwritten. Values of unconfigured
+dimensions and of undefined properties are unreachable rather than wrong, so they are only reported
+until `--prune` is given – and pruning is refused altogether while no content dimension is configured,
+because a broken dimension configuration would otherwise look like every value being obsolete.
+
+## Architecture and extension points
+
+The `MetaDataManager` is assembled by `MetaDataManagerFactory` from three interfaces, each wired to a
+default implementation in `Configuration/Objects.yaml`. Replace any of them to change the behaviour:
+
+| Interface | Default implementation | Responsibility |
+|----------------------------------|-----------------------------------------------------------|-----------------------------------------------------------------------------|
+| `Storage\MetaDataStorage` | `MetaDataStorageProviderDbalAdapter` | Persists property values in the `neos_metadata_value` table via Doctrine DBAL |
+| `DimensionSpacePointProvider\DimensionSpacePointProvider` | `DimensionSpacePointProviderContentRepositoryAdapter` | Provides valid dimension space points, the default one and the fallback chain |
+| `Configuration\MetaDataConfigurationProvider` | `MetaDataConfigurationProviderYamlAdapter` | Turns the YAML settings into `MetaDataPropertyDefinitions` |
+
+Storage implementations are deliberately dumb: they look values up by scope and must not invent any
+resolution rules. Which of the returned values wins, and whether it counts as an own or an inherited
+one, is decided by the `MetaDataManager`. `findAssets()` is the one place where precedence has to be
+applied inside the query, because resolving it per asset in PHP would mean a query per candidate – so
+the manager hands the storage the fallback chain *ordered*, from the most to the least specific
+dimension space point, and the storage applies that ranking rather than deriving one. Its docblock
+states so explicitly; for every other method the order is meaningless.
+
+`Storage\MetaDataStorageMaintenance` is an *optional* interface that allows stored values to be listed
+and removed regardless of scope. Only `assetmetadata:repair` needs it; a storage that does not implement
+it works fine, the command reports that repairing is unsupported.
+
+The value objects below `Classes/Domain/Dto` are excluded from Flow's object management, so they are
+never proxied.
+
+### Storage format
+
+All values live in a single table `neos_metadata_value` with a unique index over `asset_source_id`,
+`asset_id`, `property_name` and `dimension_hash`. Rows are deleted together with their asset via a
+foreign key with `ON DELETE CASCADE`.
+
+For localized properties the `dimension_hash` is the md5 of the JSON encoded, key-sorted dimension
+coordinates. For properties with a global scope it is the literal string `global` – an md5 is always 32
+hex characters, so the two can never collide.
+
+For a given asset and property the table therefore holds *either* one shared value *or* one value per
+dimension space point, never both. Reads always look up the scope a property is configured for, so
+values of the respective other shape are unreachable and cannot surface after a configuration change.
+
+Reading a localized property looks up the whole fallback chain in one query. The chain is ordered from
+most specific to most generic by fallback distance; the first stored value along it is the effective
+one, the first one after the requested dimension space point is the inherited one.
+
+Searching works on the same chain, in a single query per search: candidate rows are matched with a
+`LIKE` and then reduced to the ones that are not shadowed, using a `NOT EXISTS` anti-join that looks
+for a stored value closer along the chain. Localized and global scope properties are searched in the
+same statement, as two alternatives of one condition, so that an asset matching in both is still
+returned once. The leading wildcard of the `LIKE` means the index cannot be used – if that ever becomes
+a problem, a `FULLTEXT` index is the way out, and nothing in the public API would have to change.
+
+## Tests
+
+Tests are part of the regular Flow test suites:
+
+```bash
+./bin/phpunit -c Build/BuildEssentials/PhpUnit/UnitTests.xml --filter 'Neos\\MetaData'
+./bin/phpunit -c Build/BuildEssentials/PhpUnit/FunctionalTests.xml --filter 'Neos\\MetaData'
+```
-### Defined Meta Data Mappers
+They are split along the seams the package is built on:
-* **AssetModelMetaDataMapper**: Maps meta data to `Asset` models from `neos/media`. Supported are title,
- caption, copyright notice (on `neos/media` 4.2 and up), tags and collections (see configuration above).
+| Test | Subject |
+|-------------------------------------------------------|-----------------------------------------------------------------------------------|
+| `MetaDataManagerTest` | The resolution rules, with the storage and the dimension space point provider as test doubles |
+| `MetaDataRepairTest` | Which stored values contradict the configuration and what is done about them |
+| `MetaDataPropertyTypeTest` | Coercing values to a declared type and back |
+| `MetaDataStorageProviderDbalAdapterTest` | The `MetaDataStorage` implementation, **functional** |
+| `DimensionSpacePointProviderContentRepositoryAdapterTest` | The `DimensionSpacePointProvider` implementation |
+| `MetaDataConfigurationProviderYamlAdapterTest` | The `MetaDataConfigurationProvider` implementation |
-### Defined Data Transfer Objects
+The manager tests state the stored values they resolve from rather than writing them first, so they say
+what a rule *is* instead of demonstrating it through a round trip. What a storage does with a lookup is
+its own business, and is covered once per implementation.
-* **Asset**: The asset DTO provides basic data about the asset, like original file name.
-* **IPTC**: Image meta data, title and description of the photograph and the author. For
- further specifications see https://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata.
-* **EXIF**: Exchangeable image file format for digital still cameras, the technical meta data of an image.
- For further specifications see http://www.cipa.jp/std/documents/e/DC-008-Translation-2016-E.pdf.
+The storage adapter is deliberately MySQL specific – the upsert, the fallback ranking and the null safe
+correlation all use MySQL syntax, and the matching semantics of the search are those of
+`utf8mb4_unicode_ci`. Its tests therefore need a MySQL or MariaDB test database and are skipped on other
+platforms; they are the only ones that do. That test also covers the `MetaDataStorageMaintenance`
+surface that `assetmetadata:repair` is built on.
diff --git a/Resources/Private/Translations/de/Main.xlf b/Resources/Private/Translations/de/Main.xlf
new file mode 100644
index 0000000..2dbf576
--- /dev/null
+++ b/Resources/Private/Translations/de/Main.xlf
@@ -0,0 +1,19 @@
+
+
+
+
+
+ Alt Text
+ Alternativtext
+
+
+ Copyright Notice
+ Urheberrechtshinweis
+
+
+ Caption
+ Bildunterschrift
+
+
+
+
diff --git a/Resources/Private/Translations/en/Main.xlf b/Resources/Private/Translations/en/Main.xlf
new file mode 100644
index 0000000..d21b23e
--- /dev/null
+++ b/Resources/Private/Translations/en/Main.xlf
@@ -0,0 +1,16 @@
+
+
+
+
+
+ Alt Text
+
+
+ Copyright Notice
+
+
+ Caption
+
+
+
+
diff --git a/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php b/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php
new file mode 100644
index 0000000..16f3a32
--- /dev/null
+++ b/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php
@@ -0,0 +1,525 @@
+getObjectManager()->get(EntityManagerInterface::class);
+ $connection = $entityManager->getConnection();
+ if (!$connection->getDatabasePlatform() instanceof AbstractMySQLPlatform) {
+ self::markTestSkipped('The metadata storage adapter requires MySQL or MariaDB');
+ }
+
+ parent::setUp();
+
+ $this->connection = $connection;
+ $this->connection->executeStatement('CREATE TABLE IF NOT EXISTS neos_metadata_value (
+ `asset_source_id` VARCHAR(255) DEFAULT NULL,
+ `asset_id` VARCHAR(40) DEFAULT NULL,
+ `property_name` VARCHAR(40) NOT NULL,
+ `property_value` VARCHAR(250) NOT NULL,
+ `dimension_hash` VARCHAR(250) NOT NULL,
+ UNIQUE INDEX idx_unique (`asset_source_id`, `asset_id`, `property_name`, `dimension_hash`)
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
+ $this->connection->executeStatement('DELETE FROM neos_metadata_value');
+
+ $this->storage = new MetaDataStorageProviderDbalAdapter($this->connection);
+ $this->asset = MetaDataAssetReference::create('neos', 'some-asset');
+ $this->caption = MetaDataPropertyName::fromString('caption');
+ $this->de = MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de']);
+ $this->en = MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'en']);
+ }
+
+ public function tearDown(): void
+ {
+ if (($this->connection ?? null) !== null) {
+ $this->connection->executeStatement('DELETE FROM neos_metadata_value');
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * @test
+ */
+ public function valuesAreStoredAndLookedUpByDimensionHash(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Eine Katze', $this->de);
+
+ self::assertSame(
+ [$this->de->hash => 'Eine Katze'],
+ $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->de)),
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function storingAValueTwiceReplacesItInsteadOfDuplicatingIt(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Eine Katze', $this->de);
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Ein Kater', $this->de);
+
+ self::assertSame([$this->de->hash => 'Ein Kater'], $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->de)));
+ $count = $this->connection->fetchOne('SELECT COUNT(*) FROM neos_metadata_value');
+ self::assertIsNumeric($count);
+ self::assertSame(1, (int) $count);
+ }
+
+ /**
+ * @test
+ */
+ public function allMatchingValuesAreReturnedForSeveralDimensionSpacePoints(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Eine Katze', $this->de);
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+
+ $values = $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->de, $this->en));
+ self::assertCount(2, $values);
+ self::assertSame('Eine Katze', $values[$this->de->hash]);
+ self::assertSame('A cat', $values[$this->en->hash]);
+ }
+
+ /**
+ * @test
+ */
+ public function dimensionSpacePointsWithoutAValueAreAbsentFromTheResult(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+
+ self::assertSame(
+ [$this->en->hash => 'A cat'],
+ $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->de, $this->en)),
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function anEmptyScopeIsNotQueried(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+
+ self::assertSame([], $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create()));
+ }
+
+ /**
+ * @test
+ */
+ public function globalValuesAreStoredSeparatelyFromLocalizedOnes(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Shared', MetaDataGlobalScope::create());
+
+ self::assertSame(
+ [$this->en->hash => 'A cat'],
+ $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->en)),
+ 'a localized lookup must not see the shared value',
+ );
+ self::assertSame(['global' => 'Shared'], $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataGlobalScope::create()));
+ }
+
+ /**
+ * @test
+ */
+ public function unsettingAValueOnlyAffectsTheGivenScope(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Eine Katze', $this->de);
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+ $this->storage->unsetMetaDataPropertyValue($this->asset, $this->caption, $this->de);
+
+ self::assertSame(
+ [$this->en->hash => 'A cat'],
+ $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->de, $this->en)),
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function storedValuesOfAllAssetsCanBeIterated(): void
+ {
+ $otherAsset = MetaDataAssetReference::create('other-source', 'other-asset');
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+ $this->storage->setMetaDataPropertyValue($otherAsset, $this->caption, 'Shared', MetaDataGlobalScope::create());
+
+ $storedValues = iterator_to_array($this->storage->findAllStoredValues(), false);
+ usort($storedValues, static fn (MetaDataStoredValue $a, MetaDataStoredValue $b) => $a->assetReference->assetId <=> $b->assetReference->assetId);
+
+ self::assertCount(2, $storedValues);
+ self::assertSame('other-asset', $storedValues[0]->assetReference->assetId);
+ self::assertSame('other-source', $storedValues[0]->assetReference->assetSourceId);
+ self::assertTrue($storedValues[0]->global);
+ self::assertSame('Shared', $storedValues[0]->value);
+ self::assertFalse($storedValues[1]->global);
+ self::assertSame($this->en->hash, $storedValues[1]->dimensionHash);
+ }
+
+ /**
+ * @test
+ */
+ public function deletingStoredValuesRemovesExactlyThoseRows(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Eine Katze', $this->de);
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Shared', MetaDataGlobalScope::create());
+
+ $toDelete = array_values(array_filter(
+ iterator_to_array($this->storage->findAllStoredValues(), false),
+ static fn (MetaDataStoredValue $storedValue) => $storedValue->global,
+ ));
+ self::assertSame(1, $this->storage->deleteStoredValues(...$toDelete));
+
+ self::assertSame([], $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataGlobalScope::create()));
+ self::assertCount(2, $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->de, $this->en)));
+ }
+
+ // ----------------------- asset isolation
+
+ /**
+ * @test
+ */
+ public function valuesOfOtherAssetsAreNotReturned(): void
+ {
+ $otherAsset = MetaDataAssetReference::create('neos', 'other-asset');
+ $this->storage->setMetaDataPropertyValue($otherAsset, $this->caption, 'A cat', $this->en);
+
+ self::assertSame([], $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->en)));
+ }
+
+ /**
+ * @test
+ */
+ public function valuesOfOtherAssetSourcesAreNotReturned(): void
+ {
+ $sameAssetInAnotherSource = MetaDataAssetReference::create('other-source', 'some-asset');
+ $this->storage->setMetaDataPropertyValue($sameAssetInAnotherSource, $this->caption, 'A cat', $this->en);
+
+ self::assertSame([], $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->en)));
+ }
+
+ /**
+ * @test
+ */
+ public function unsettingAValueOfOneAssetLeavesTheOtherAssetsAlone(): void
+ {
+ $otherAsset = MetaDataAssetReference::create('neos', 'other-asset');
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+ $this->storage->setMetaDataPropertyValue($otherAsset, $this->caption, 'Another cat', $this->en);
+
+ $this->storage->unsetMetaDataPropertyValue($this->asset, $this->caption, $this->en);
+
+ self::assertSame(
+ [$this->en->hash => 'Another cat'],
+ $this->storage->getMetaDataPropertyValues($otherAsset, $this->caption, MetaDataDimensionSpacePoints::create($this->en)),
+ );
+ }
+
+ // ----------------------- maintenance
+
+ /**
+ * @test
+ */
+ public function deletingAValueThatIsNotStoredChangesNothing(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+ $absent = new MetaDataStoredValue($this->asset, $this->caption, $this->de->hash, false, 'Eine Katze');
+
+ self::assertSame(0, $this->storage->deleteStoredValues($absent));
+ self::assertCount(1, $this->storedValues());
+ }
+
+ /**
+ * @test
+ */
+ public function deletingWithoutAnyValuesChangesNothing(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+
+ self::assertSame(0, $this->storage->deleteStoredValues());
+ self::assertCount(1, $this->storedValues());
+ }
+
+ /**
+ * The dimension hash of a stored value can be handed straight back to the storage, which is what
+ * `assetmetadata:repair` relies on
+ *
+ * @test
+ */
+ public function storedValuesCanBeDeletedByWhatWasIterated(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Shared', MetaDataGlobalScope::create());
+
+ self::assertSame(2, $this->storage->deleteStoredValues(...$this->storedValues()));
+ self::assertSame([], $this->storedValues());
+ }
+
+ /**
+ * @test
+ */
+ public function storedValuesOfUnconfiguredDimensionsAndUndefinedPropertiesAreIterated(): void
+ {
+ $this->addRawValue($this->asset, 'formerProperty', $this->en->hash, 'obsolete');
+ $this->addRawValue($this->asset, 'caption', 'some-obsolete-hash', 'Un gato');
+
+ $storedValues = $this->storedValues();
+ usort($storedValues, static fn (MetaDataStoredValue $a, MetaDataStoredValue $b) => $a->propertyName->value <=> $b->propertyName->value);
+
+ self::assertCount(2, $storedValues, 'repairing must be able to see values that reads can never return');
+ self::assertSame('caption', $storedValues[0]->propertyName->value);
+ self::assertSame('some-obsolete-hash', $storedValues[0]->dimensionHash);
+ self::assertFalse($storedValues[0]->global);
+ self::assertSame('formerProperty', $storedValues[1]->propertyName->value);
+ }
+
+ /**
+ * @test
+ */
+ public function anEmptyTableIteratesToNothing(): void
+ {
+ self::assertSame([], $this->storedValues());
+ }
+
+ // ----------------------- searching
+
+ /**
+ * @test
+ */
+ public function assetsAreFoundBySearchTermInLocalizedAndGlobalProperties(): void
+ {
+ $other = MetaDataAssetReference::create('neos', 'other-asset');
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+ $this->storage->setMetaDataPropertyValue($other, MetaDataPropertyName::fromString('copyright'), '© Cat Photos', MetaDataGlobalScope::create());
+
+ self::assertSame(['neos:other-asset', 'neos:some-asset'], $this->find('cat'), 'and ordered by asset source id, then asset id');
+ }
+
+ /**
+ * @test
+ */
+ public function theSearchTermMatchesAnywhereInAValueAndIgnoresCase(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A CATalogue picture', $this->en);
+
+ self::assertSame(['neos:some-asset'], $this->find('cat'));
+ }
+
+ /**
+ * @test
+ */
+ public function inheritedValuesAreFound(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+
+ self::assertSame(['neos:some-asset'], $this->find('cat', chain: [$this->de, $this->en]));
+ }
+
+ /**
+ * @test
+ */
+ public function shadowedValuesAreNotFound(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Eine Katze', $this->de);
+
+ self::assertSame([], $this->find('cat', chain: [$this->de, $this->en]), 'the German value overrides the English one');
+ self::assertSame(['neos:some-asset'], $this->find('cat', chain: [$this->en]));
+ }
+
+ /**
+ * @test
+ */
+ public function valuesOutsideTheChainAreNotFound(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Un chat', MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'fr']));
+
+ self::assertSame([], $this->find('chat', chain: [$this->de, $this->en]));
+ }
+
+ /**
+ * @test
+ */
+ public function globalValuesAreFoundRegardlessOfTheChain(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, MetaDataPropertyName::fromString('copyright'), '© Acme', MetaDataGlobalScope::create());
+
+ self::assertSame(['neos:some-asset'], $this->find('acme', chain: [$this->de, $this->en]));
+ self::assertSame(['neos:some-asset'], $this->find('acme', chain: [$this->en]));
+ }
+
+ /**
+ * @test
+ */
+ public function anAssetMatchingSeveralTimesIsReturnedOnce(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+ $this->storage->setMetaDataPropertyValue($this->asset, MetaDataPropertyName::fromString('copyright'), '© Cat Photos', MetaDataGlobalScope::create());
+
+ self::assertSame(['neos:some-asset'], $this->find('cat'));
+ }
+
+ /**
+ * @test
+ */
+ public function theSearchCanBeRestrictedToAnAssetSource(): void
+ {
+ $sameAssetInAnotherSource = MetaDataAssetReference::create('other-source', 'some-asset');
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+ $this->storage->setMetaDataPropertyValue($sameAssetInAnotherSource, $this->caption, 'A cat', $this->en);
+
+ self::assertSame(['other-source:some-asset'], $this->find('cat', assetSourceId: 'other-source'));
+ }
+
+ /**
+ * @test
+ */
+ public function anOmittedSearchTermMatchesEveryAssetWithAValue(): void
+ {
+ $other = MetaDataAssetReference::create('neos', 'other-asset');
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+ $this->storage->setMetaDataPropertyValue($other, MetaDataPropertyName::fromString('copyright'), '© Acme', MetaDataGlobalScope::create());
+
+ self::assertSame(['neos:other-asset', 'neos:some-asset'], $this->find(null));
+ }
+
+ /**
+ * @test
+ */
+ public function likeWildcardsInTheSearchTermAreEscaped(): void
+ {
+ $discounted = MetaDataAssetReference::create('neos', 'discounted');
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+ $this->storage->setMetaDataPropertyValue($discounted, $this->caption, 'Reduced by 50%', $this->en);
+
+ self::assertSame(['neos:discounted'], $this->find('50%'));
+ self::assertSame([], $this->find('c_t'));
+ self::assertSame([], $this->find('\\'));
+ }
+
+ /**
+ * @test
+ */
+ public function valuesOfAScopeThatContradictsTheSearchedOneAreNotFound(): void
+ {
+ $this->addRawValue($this->asset, 'copyright', $this->en->hash, '© Stale');
+ $this->addRawValue($this->asset, 'caption', 'global', 'Stale caption');
+
+ self::assertSame([], $this->find('stale', chain: [$this->en]));
+ }
+
+ /**
+ * @test
+ */
+ public function searchingWithoutAnyPropertyNamesReturnsNothing(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+
+ self::assertSame([], iterator_to_array($this->storage->findAssets(
+ null,
+ 'cat',
+ MetaDataPropertyNames::createEmpty(),
+ MetaDataDimensionSpacePoints::create($this->en),
+ MetaDataPropertyNames::createEmpty(),
+ ), false), 'an empty IN () would be a SQL error, so no query must be issued at all');
+ }
+
+ /**
+ * @test
+ */
+ public function searchingWithAnEmptyChainIgnoresLocalizedProperties(): void
+ {
+ $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en);
+
+ self::assertSame([], iterator_to_array($this->storage->findAssets(
+ null,
+ 'cat',
+ MetaDataPropertyNames::create($this->caption),
+ MetaDataDimensionSpacePoints::create(),
+ MetaDataPropertyNames::createEmpty(),
+ ), false));
+ }
+
+ // -----------------------
+
+ /**
+ * Searches `caption` as a localized and `copyright` as a global scope property, which is how the
+ * manager splits the default configuration.
+ *
+ * @param list|null $chain ordered from the most to the least specific, defaults to English only
+ * @return list the matched asset references as ":"
+ */
+ private function find(?string $searchTerm, ?array $chain = null, ?string $assetSourceId = null): array
+ {
+ $matches = [];
+ $assetReferences = $this->storage->findAssets(
+ $assetSourceId,
+ $searchTerm,
+ MetaDataPropertyNames::create('caption'),
+ MetaDataDimensionSpacePoints::create(...($chain ?? [$this->en])),
+ MetaDataPropertyNames::create('copyright'),
+ );
+ foreach ($assetReferences as $assetReference) {
+ $matches[] = $assetReference->assetSourceId . ':' . $assetReference->assetId;
+ }
+ return $matches;
+ }
+
+ private function addRawValue(MetaDataAssetReference $assetReference, string $propertyName, string $dimensionHash, string $value): void
+ {
+ $this->connection->insert('neos_metadata_value', [
+ 'asset_source_id' => $assetReference->assetSourceId,
+ 'asset_id' => $assetReference->assetId,
+ 'property_name' => $propertyName,
+ 'property_value' => $value,
+ 'dimension_hash' => $dimensionHash,
+ ]);
+ }
+
+ /**
+ * @return list
+ */
+ private function storedValues(): array
+ {
+ return iterator_to_array($this->storage->findAllStoredValues(), false);
+ }
+}
diff --git a/Tests/Unit/Configuration/MetaDataConfigurationProviderYamlAdapterTest.php b/Tests/Unit/Configuration/MetaDataConfigurationProviderYamlAdapterTest.php
new file mode 100644
index 0000000..4bdcb9c
--- /dev/null
+++ b/Tests/Unit/Configuration/MetaDataConfigurationProviderYamlAdapterTest.php
@@ -0,0 +1,230 @@
+translator = $this->createMock(Translator::class);
+ }
+
+ /**
+ * @test
+ */
+ public function propertiesAreKeyedByTheirName(): void
+ {
+ $definitions = $this->definitionsFor(['caption' => [], 'copyright' => []]);
+
+ self::assertTrue($definitions->include(MetaDataPropertyName::fromString('caption')));
+ self::assertTrue($definitions->include(MetaDataPropertyName::fromString('copyright')));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function types(): iterable
+ {
+ yield 'string' => ['configuredType' => 'string', 'expectedType' => MetaDataPropertyType::string];
+ yield 'integer' => ['configuredType' => 'integer', 'expectedType' => MetaDataPropertyType::integer];
+ yield 'boolean' => ['configuredType' => 'boolean', 'expectedType' => MetaDataPropertyType::boolean];
+ yield 'omitted defaults to string' => ['configuredType' => null, 'expectedType' => MetaDataPropertyType::string];
+ yield 'unknown defaults to string' => ['configuredType' => 'float', 'expectedType' => MetaDataPropertyType::string];
+ }
+
+ /**
+ * @dataProvider types
+ * @test
+ */
+ public function theTypeIsParsed(?string $configuredType, MetaDataPropertyType $expectedType): void
+ {
+ $configuration = $configuredType === null ? [] : ['type' => $configuredType];
+
+ self::assertSame($expectedType, $this->definitionFor($configuration)->type);
+ }
+
+ /**
+ * @test
+ */
+ public function theScopeIsLocalizedUnlessConfiguredOtherwise(): void
+ {
+ self::assertFalse($this->definitionFor([])->globalScope);
+ self::assertFalse($this->definitionFor(['globalScope' => false])->globalScope);
+ self::assertTrue($this->definitionFor(['globalScope' => true])->globalScope);
+ }
+
+ /**
+ * @test
+ */
+ public function aLabelIsUsedVerbatim(): void
+ {
+ $this->translator->expects(self::never())->method('translateById');
+
+ $ui = $this->definitionFor(['ui' => ['label' => 'Some label']])->ui;
+ self::assertNotNull($ui);
+ self::assertSame('Some label', $ui->label);
+ }
+
+ /**
+ * @test
+ */
+ public function theLiteralLabelI18nIsTranslated(): void
+ {
+ $this->translator->expects(self::once())
+ ->method('translateById')
+ ->with('properties.caption', [], null, null, 'Main', 'Neos.MetaData')
+ ->willReturn('Bildunterschrift');
+
+ $ui = $this->definitionFor(['ui' => ['label' => 'i18n']])->ui;
+ self::assertNotNull($ui);
+ self::assertSame('Bildunterschrift', $ui->label);
+ }
+
+ /**
+ * @test
+ */
+ public function aShortHandStringLabelIsTranslatedAgainstTheReferencedPackageAndSource(): void
+ {
+ $this->translator->expects(self::once())
+ ->method('translateById')
+ ->with('properties.caption', [], null, null, 'Main', 'Neos.MetaData.Extractor')
+ ->willReturn('Bildunterschrift');
+
+ $ui = $this->definitionFor(['ui' => ['label' => 'Neos.MetaData.Extractor:Main:properties.caption']])->ui;
+ self::assertNotNull($ui);
+ self::assertSame('Bildunterschrift', $ui->label);
+ }
+
+ /**
+ * @test
+ */
+ public function aShortHandStringWithDottedSourceIsNormalizedBeforeTranslation(): void
+ {
+ $this->translator->expects(self::once())
+ ->method('translateById')
+ ->with('properties.caption', [], null, null, 'Main/Foo', 'Neos.MetaData.Extractor')
+ ->willReturn('Bildunterschrift');
+
+ $ui = $this->definitionFor(['ui' => ['label' => 'Neos.MetaData.Extractor:Main.Foo:properties.caption']])->ui;
+ self::assertNotNull($ui);
+ self::assertSame('Bildunterschrift', $ui->label);
+ }
+
+ /**
+ * @test
+ */
+ public function anUntranslatedShortHandStringFallsBackToThePropertyName(): void
+ {
+ $this->translator->method('translateById')->willReturn(null);
+
+ $ui = $this->definitionFor(['ui' => ['label' => 'Neos.MetaData.Extractor:Main:properties.caption']])->ui;
+ self::assertNotNull($ui);
+ self::assertSame('caption', $ui->label);
+ }
+
+ /**
+ * @test
+ */
+ public function anUntranslatedLabelFallsBackToThePropertyName(): void
+ {
+ $this->translator->method('translateById')->willReturn(null);
+
+ $ui = $this->definitionFor(['ui' => ['label' => 'i18n']])->ui;
+ self::assertNotNull($ui);
+ self::assertSame('caption', $ui->label);
+ }
+
+ /**
+ * @test
+ */
+ public function theEditorAndItsOptionsAreParsed(): void
+ {
+ $definition = $this->definitionFor([
+ 'ui' => [
+ 'inspector' => [
+ 'editor' => 'Neos.Neos/Inspector/Editors/TextAreaEditor',
+ 'editorOptions' => ['rows' => 7],
+ ],
+ ],
+ ]);
+
+ self::assertNotNull($definition->ui);
+ self::assertSame('Neos.Neos/Inspector/Editors/TextAreaEditor', $definition->ui->editorDefinition->editorType);
+ self::assertSame(['rows' => 7], $definition->ui->editorDefinition->options);
+ }
+
+ /**
+ * A property without any `ui` configuration must not break the parsing
+ *
+ * @test
+ */
+ public function propertiesWithoutUiConfigurationAreParsed(): void
+ {
+ $definition = $this->definitionFor([]);
+
+ self::assertSame('caption', $definition->name->value);
+ }
+
+ /**
+ * @test
+ */
+ public function aMissingUiConfigLeadsToAnEmptyUiDefinition(): void
+ {
+ $definition = $this->definitionFor(['type' => 'string']);
+ self::assertNull($definition->ui);
+ }
+
+ /**
+ * @test
+ */
+ public function anEmptyConfigurationLeadsToNoDefinitions(): void
+ {
+ self::assertSame([], iterator_to_array($this->definitionsFor([])));
+ }
+
+ /**
+ * @test
+ */
+ public function propertiesConfiguredToNullAreSkipped(): void
+ {
+ $definitions = $this->definitionsFor(['caption' => [], 'copyright' => null]);
+
+ self::assertTrue($definitions->include(MetaDataPropertyName::fromString('caption')));
+ self::assertFalse($definitions->include(MetaDataPropertyName::fromString('copyright')));
+ }
+
+ // -----------------------
+
+ /**
+ * @param array $configuration configuration of a single property named "caption"
+ */
+ private function definitionFor(array $configuration): MetaDataPropertyDefinition
+ {
+ return $this->definitionsFor(['caption' => $configuration])->get(MetaDataPropertyName::fromString('caption'));
+ }
+
+ /**
+ * @param array $configuration
+ */
+ private function definitionsFor(array $configuration): MetaDataPropertyDefinitions
+ {
+ /** @phpstan-ignore argument.type */
+ return (new MetaDataConfigurationProviderYamlAdapter($configuration, $this->translator))->getPropertyConfiguration();
+ }
+}
diff --git a/Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php b/Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php
new file mode 100644
index 0000000..ef84b51
--- /dev/null
+++ b/Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php
@@ -0,0 +1,194 @@
+adapter([
+ 'language' => ['default' => 'en', 'defaultPreset' => 'en', 'presets' => ['en' => ['values' => ['en']], 'de' => ['values' => ['de', 'en']]]],
+ 'country' => ['default' => 'us', 'defaultPreset' => 'us', 'presets' => ['us' => ['values' => ['us']], 'at' => ['values' => ['at', 'us']]]],
+ ]);
+
+ self::assertSame(['language' => 'en', 'country' => 'us'], $adapter->getDefaultDimensionSpacePoint()->coordinates);
+ }
+
+ /**
+ * @test
+ */
+ public function theChainStartsWithTheDimensionSpacePointItself(): void
+ {
+ $adapter = $this->adapter(['language' => ['default' => 'en', 'defaultPreset' => 'en', 'presets' => ['en' => ['values' => ['en']], 'de' => ['values' => ['de', 'en']]]]]);
+
+ $chain = $adapter->getDimensionSpacePointChain(MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de']));
+
+ self::assertSame([['language' => 'de'], ['language' => 'en']], self::coordinates($chain));
+ }
+
+ /**
+ * @test
+ */
+ public function aDimensionSpacePointWithoutFallbacksIsItsOwnChain(): void
+ {
+ $adapter = $this->adapter(['language' => ['default' => 'en', 'defaultPreset' => 'en', 'presets' => ['en' => ['values' => ['en']], 'de' => ['values' => ['de', 'en']]]]]);
+
+ $chain = $adapter->getDimensionSpacePointChain(MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'en']));
+
+ self::assertSame([['language' => 'en']], self::coordinates($chain));
+ }
+
+ /**
+ * @test
+ */
+ public function chainsOfSeveralDimensionsAreOrderedByTotalFallbackDistance(): void
+ {
+ $adapter = $this->adapter([
+ 'language' => ['default' => 'en', 'defaultPreset' => 'en', 'presets' => ['en' => ['values' => ['en']], 'de' => ['values' => ['de', 'en']]]],
+ 'country' => ['default' => 'us', 'defaultPreset' => 'us', 'presets' => ['us' => ['values' => ['us']], 'at' => ['values' => ['at', 'us']]]],
+ ]);
+
+ $chain = $adapter->getDimensionSpacePointChain(MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de', 'country' => 'at']));
+
+ self::assertSame([
+ ['language' => 'de', 'country' => 'at'],
+ ['language' => 'de', 'country' => 'us'],
+ ['language' => 'en', 'country' => 'at'],
+ ['language' => 'en', 'country' => 'us'],
+ ], self::coordinates($chain), 'the most specific combination must come first, the most generic last');
+ }
+
+ /**
+ * @test
+ */
+ public function withoutContentDimensionsTheOnlyDimensionSpacePointIsTheEmptyOne(): void
+ {
+ $adapter = $this->adapter([]);
+
+ self::assertSame([], $adapter->getDefaultDimensionSpacePoint()->coordinates);
+ self::assertTrue($adapter->isDimensionSpacePointValid(MetaDataDimensionSpacePoint::fromCoordinates([])));
+ self::assertFalse($adapter->isDimensionSpacePointValid(MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de'])));
+ }
+
+ /**
+ * @test
+ */
+ public function unknownDimensionValuesAreNotValid(): void
+ {
+ $adapter = $this->adapter(['language' => ['default' => 'en', 'defaultPreset' => 'en', 'presets' => ['en' => ['values' => ['en']], 'de' => ['values' => ['de', 'en']]]]]);
+
+ self::assertTrue($adapter->isDimensionSpacePointValid(MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de'])));
+ self::assertFalse($adapter->isDimensionSpacePointValid(MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'es'])));
+ self::assertFalse($adapter->isDimensionSpacePointValid(MetaDataDimensionSpacePoint::fromCoordinates([])), 'a dimension must not be omitted');
+ }
+
+ /**
+ * A preset identifier does not have to equal the primary value of that preset. Everything else in
+ * the adapter works with values, so enumerating by identifier would produce dimension space points
+ * that cannot be validated or resolved.
+ *
+ * @test
+ */
+ public function dimensionSpacePointsAreEnumeratedByPresetValueRatherThanByPresetIdentifier(): void
+ {
+ $adapter = $this->adapter([
+ 'language' => [
+ 'default' => 'en',
+ 'defaultPreset' => 'english',
+ 'presets' => ['english' => ['values' => ['en']], 'german' => ['values' => ['de', 'en']]],
+ ],
+ ]);
+
+ self::assertSame([['language' => 'en'], ['language' => 'de']], self::coordinates($adapter->getDimensionSpacePoints()));
+ }
+
+ /**
+ * @test
+ */
+ public function everyEnumeratedDimensionSpacePointIsValid(): void
+ {
+ $adapter = $this->adapter([
+ 'language' => [
+ 'default' => 'en',
+ 'defaultPreset' => 'english',
+ 'presets' => ['english' => ['values' => ['en']], 'german' => ['values' => ['de', 'en']]],
+ ],
+ ]);
+
+ foreach ($adapter->getDimensionSpacePoints() as $dimensionSpacePoint) {
+ self::assertTrue(
+ $adapter->isDimensionSpacePointValid($dimensionSpacePoint),
+ sprintf('%s was enumerated but is not considered valid', $dimensionSpacePoint),
+ );
+ }
+ self::assertTrue(
+ $adapter->getDimensionSpacePoints()->include($adapter->getDefaultDimensionSpacePoint()),
+ 'the default dimension space point must be among the enumerated ones',
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function presetsWithoutValuesAreNotEnumerated(): void
+ {
+ $adapter = $this->adapter([
+ 'language' => ['default' => 'en', 'defaultPreset' => 'en', 'presets' => ['en' => ['values' => ['en']], 'broken' => []]],
+ ]);
+
+ self::assertSame([['language' => 'en']], self::coordinates($adapter->getDimensionSpacePoints()));
+ }
+
+ // -----------------------
+
+ /**
+ * @param array $presets
+ */
+ private function adapter(array $presets): DimensionSpacePointProviderContentRepositoryAdapter
+ {
+ $presetSource = new ConfigurationContentDimensionPresetSource();
+ $presetSource->setConfiguration($presets);
+ return new DimensionSpacePointProviderContentRepositoryAdapter($presetSource);
+ }
+
+ /**
+ * @param iterable $dimensionSpacePoints
+ * @return list>
+ */
+ private static function coordinates(iterable $dimensionSpacePoints): array
+ {
+ $coordinates = [];
+ foreach ($dimensionSpacePoints as $dimensionSpacePoint) {
+ $coordinates[] = $dimensionSpacePoint->coordinates;
+ }
+ return $coordinates;
+ }
+}
diff --git a/Tests/Unit/Domain/Dto/MetaDataPropertyTypeTest.php b/Tests/Unit/Domain/Dto/MetaDataPropertyTypeTest.php
new file mode 100644
index 0000000..2cbd3d7
--- /dev/null
+++ b/Tests/Unit/Domain/Dto/MetaDataPropertyTypeTest.php
@@ -0,0 +1,120 @@
+
+ */
+ public static function coercibleValues(): iterable
+ {
+ yield 'string from string' => ['type' => MetaDataPropertyType::string, 'value' => 'A cat', 'expected' => 'A cat'];
+ yield 'string from integer' => ['type' => MetaDataPropertyType::string, 'value' => 42, 'expected' => '42'];
+ yield 'string from boolean' => ['type' => MetaDataPropertyType::string, 'value' => true, 'expected' => '1'];
+ yield 'string is not trimmed' => ['type' => MetaDataPropertyType::string, 'value' => ' padded ', 'expected' => ' padded '];
+
+ yield 'integer from integer' => ['type' => MetaDataPropertyType::integer, 'value' => 42, 'expected' => '42'];
+ yield 'integer from numeric string' => ['type' => MetaDataPropertyType::integer, 'value' => '42', 'expected' => '42'];
+ yield 'integer from padded string' => ['type' => MetaDataPropertyType::integer, 'value' => ' 42 ', 'expected' => '42'];
+ yield 'negative integer' => ['type' => MetaDataPropertyType::integer, 'value' => '-42', 'expected' => '-42'];
+ yield 'integer from boolean' => ['type' => MetaDataPropertyType::integer, 'value' => true, 'expected' => '1'];
+
+ yield 'boolean from boolean' => ['type' => MetaDataPropertyType::boolean, 'value' => true, 'expected' => '1'];
+ yield 'boolean from false' => ['type' => MetaDataPropertyType::boolean, 'value' => false, 'expected' => '0'];
+ yield 'boolean from "true"' => ['type' => MetaDataPropertyType::boolean, 'value' => 'true', 'expected' => '1'];
+ yield 'boolean from "TRUE"' => ['type' => MetaDataPropertyType::boolean, 'value' => 'TRUE', 'expected' => '1'];
+ yield 'boolean from "on"' => ['type' => MetaDataPropertyType::boolean, 'value' => 'on', 'expected' => '1'];
+ yield 'boolean from "yes"' => ['type' => MetaDataPropertyType::boolean, 'value' => 'yes', 'expected' => '1'];
+ yield 'boolean from "false"' => ['type' => MetaDataPropertyType::boolean, 'value' => 'false', 'expected' => '0'];
+ yield 'boolean from "no"' => ['type' => MetaDataPropertyType::boolean, 'value' => 'no', 'expected' => '0'];
+ yield 'boolean from 1' => ['type' => MetaDataPropertyType::boolean, 'value' => 1, 'expected' => '1'];
+ yield 'boolean from 0' => ['type' => MetaDataPropertyType::boolean, 'value' => 0, 'expected' => '0'];
+ }
+
+ /**
+ * @dataProvider coercibleValues
+ * @test
+ */
+ public function valuesAreCoercedToTheirStoredRepresentation(MetaDataPropertyType $type, string|int|bool $value, string $expected): void
+ {
+ self::assertSame($expected, $type->coerceForStorage($value));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function incoercibleValues(): iterable
+ {
+ yield 'integer from words' => ['type' => MetaDataPropertyType::integer, 'value' => 'abc'];
+ yield 'integer from empty string' => ['type' => MetaDataPropertyType::integer, 'value' => ''];
+ yield 'integer from decimal' => ['type' => MetaDataPropertyType::integer, 'value' => '4.2'];
+ yield 'integer from partially numeric' => ['type' => MetaDataPropertyType::integer, 'value' => '42px'];
+
+ yield 'boolean from words' => ['type' => MetaDataPropertyType::boolean, 'value' => 'maybe'];
+ yield 'boolean from empty string' => ['type' => MetaDataPropertyType::boolean, 'value' => ''];
+ yield 'boolean from other integer' => ['type' => MetaDataPropertyType::boolean, 'value' => 2];
+ }
+
+ /**
+ * @dataProvider incoercibleValues
+ * @test
+ */
+ public function valuesThatCannotBeInterpretedAreRejected(MetaDataPropertyType $type, string|int|bool $value): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionCode(1785715201);
+ $type->coerceForStorage($value);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function storedValues(): iterable
+ {
+ yield 'string' => ['type' => MetaDataPropertyType::string, 'value' => 'A cat', 'expected' => 'A cat'];
+ yield 'integer' => ['type' => MetaDataPropertyType::integer, 'value' => '42', 'expected' => 42];
+ yield 'negative integer' => ['type' => MetaDataPropertyType::integer, 'value' => '-42', 'expected' => -42];
+ yield 'true' => ['type' => MetaDataPropertyType::boolean, 'value' => '1', 'expected' => true];
+ yield 'false' => ['type' => MetaDataPropertyType::boolean, 'value' => '0', 'expected' => false];
+ }
+
+ /**
+ * @dataProvider storedValues
+ * @test
+ */
+ public function storedValuesAreReadBackAsTheirType(MetaDataPropertyType $type, string $value, string|int|bool $expected): void
+ {
+ self::assertSame($expected, $type->fromStoredValue($value));
+ }
+
+ /**
+ * @test
+ */
+ public function everyCoercibleValueSurvivesTheRoundTrip(): void
+ {
+ foreach (self::coercibleValues() as $name => $case) {
+ $stored = $case['type']->coerceForStorage($case['value']);
+ self::assertNotNull($case['type']->fromStoredValue($stored), sprintf('"%s" is not readable again', $name));
+ }
+ }
+
+ /**
+ * Reading must not throw - it meets values that were written before a property was given its
+ * current type
+ *
+ * @test
+ */
+ public function storedValuesThatCannotBeInterpretedAreReadAsNull(): void
+ {
+ self::assertNull(MetaDataPropertyType::integer->fromStoredValue('abc'));
+ self::assertNull(MetaDataPropertyType::boolean->fromStoredValue('maybe'));
+ self::assertSame('42', MetaDataPropertyType::string->fromStoredValue('42'), 'anything is readable as a string');
+ }
+}
diff --git a/Tests/Unit/Fixtures/DimensionSpacePointProviderMocks.php b/Tests/Unit/Fixtures/DimensionSpacePointProviderMocks.php
new file mode 100644
index 0000000..a3264ff
--- /dev/null
+++ b/Tests/Unit/Fixtures/DimensionSpacePointProviderMocks.php
@@ -0,0 +1,76 @@
+ en, fr -> en and en, and en as the
+ * default one
+ */
+ protected function createLanguageDimensions(): DimensionSpacePointProvider&MockObject
+ {
+ $en = self::language('en');
+ $de = self::language('de');
+ $fr = self::language('fr');
+ return $this->createDimensions(
+ $en,
+ MetaDataDimensionSpacePoints::create($en, $de, $fr),
+ [
+ $en->hash => [$en],
+ $de->hash => [$de, $en],
+ $fr->hash => [$fr, $en],
+ ],
+ );
+ }
+
+ /**
+ * No content dimensions at all: the only valid dimension space point is the empty one
+ */
+ protected function createEmptyDimensions(): DimensionSpacePointProvider&MockObject
+ {
+ $empty = MetaDataDimensionSpacePoint::fromCoordinates([]);
+ return $this->createDimensions($empty, MetaDataDimensionSpacePoints::create($empty), [$empty->hash => [$empty]]);
+ }
+
+ protected static function language(string $value): MetaDataDimensionSpacePoint
+ {
+ return MetaDataDimensionSpacePoint::fromCoordinates(['language' => $value]);
+ }
+
+ /**
+ * @param array> $chainsByHash ordered from the most to the least specific
+ */
+ private function createDimensions(
+ MetaDataDimensionSpacePoint $defaultDimensionSpacePoint,
+ MetaDataDimensionSpacePoints $dimensionSpacePoints,
+ array $chainsByHash,
+ ): DimensionSpacePointProvider&MockObject {
+ $provider = $this->createMock(DimensionSpacePointProvider::class);
+ $provider->method('getDimensionSpacePoints')->willReturn($dimensionSpacePoints);
+ $provider->method('getDefaultDimensionSpacePoint')->willReturn($defaultDimensionSpacePoint);
+ $provider->method('isDimensionSpacePointValid')->willReturnCallback(
+ static fn (MetaDataDimensionSpacePoint $dimensionSpacePoint) => array_key_exists($dimensionSpacePoint->hash, $chainsByHash)
+ );
+ $provider->method('getDimensionSpacePointChain')->willReturnCallback(
+ static fn (MetaDataDimensionSpacePoint $dimensionSpacePoint) => MetaDataDimensionSpacePoints::create(
+ ...($chainsByHash[$dimensionSpacePoint->hash] ?? [$dimensionSpacePoint])
+ )
+ );
+ return $provider;
+ }
+}
diff --git a/Tests/Unit/Fixtures/MaintainableMetaDataStorage.php b/Tests/Unit/Fixtures/MaintainableMetaDataStorage.php
new file mode 100644
index 0000000..3f25714
--- /dev/null
+++ b/Tests/Unit/Fixtures/MaintainableMetaDataStorage.php
@@ -0,0 +1,18 @@
+ $globalScopeByPropertyName
+ */
+ public static function create(array $globalScopeByPropertyName): MetaDataPropertyDefinitions
+ {
+ $definitions = [];
+ foreach ($globalScopeByPropertyName as $propertyName => $globalScope) {
+ $definitions[] = self::definition($propertyName, MetaDataPropertyType::string, $globalScope);
+ }
+ return MetaDataPropertyDefinitions::create(...$definitions);
+ }
+
+ /**
+ * `copyright` is shared by all dimensions, `caption` is localized
+ */
+ public static function default(): MetaDataPropertyDefinitions
+ {
+ return self::create(['copyright' => true, 'caption' => false]);
+ }
+
+ /**
+ * The default definitions plus a localized `width` of type integer and a localized `featured` of
+ * type boolean
+ */
+ public static function typed(): MetaDataPropertyDefinitions
+ {
+ return MetaDataPropertyDefinitions::create(
+ self::definition('copyright', MetaDataPropertyType::string, true),
+ self::definition('caption', MetaDataPropertyType::string, false),
+ self::definition('width', MetaDataPropertyType::integer, false),
+ self::definition('featured', MetaDataPropertyType::boolean, false),
+ );
+ }
+
+ // -----------------------
+
+ private static function definition(string $propertyName, MetaDataPropertyType $type, bool $globalScope): MetaDataPropertyDefinition
+ {
+ return new MetaDataPropertyDefinition(
+ MetaDataPropertyName::fromString($propertyName),
+ $type,
+ $globalScope,
+ new MetaDataPropertyUiDefinition($propertyName, MetaDataEditorDefinition::default()),
+ );
+ }
+}
diff --git a/Tests/Unit/Maintenance/MetaDataRepairTest.php b/Tests/Unit/Maintenance/MetaDataRepairTest.php
new file mode 100644
index 0000000..d3d29d0
--- /dev/null
+++ b/Tests/Unit/Maintenance/MetaDataRepairTest.php
@@ -0,0 +1,338 @@
+
+ */
+ private array $calls = [];
+
+ public function setUp(): void
+ {
+ $this->de = self::language('de');
+ $this->en = self::language('en');
+ $this->fr = self::language('fr');
+ $this->asset = MetaDataAssetReference::create('neos', 'some-asset');
+
+ $dimensions = $this->createLanguageDimensions();
+ $this->storage = $this->createMock(MaintainableMetaDataStorage::class);
+ $this->storage->method('setMetaDataPropertyValue')->willReturnCallback(
+ function (MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, string|int|bool $value, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void {
+ $this->calls[] = sprintf(
+ 'set %s of %s to "%s" in %s',
+ $propertyName->value,
+ $assetReference->assetId,
+ $value,
+ $scope instanceof MetaDataGlobalScope ? 'global' : $scope->coordinates['language'],
+ );
+ }
+ );
+ $this->storage->method('deleteStoredValues')->willReturnCallback(
+ function (MetaDataStoredValue ...$storedValues): int {
+ foreach ($storedValues as $storedValue) {
+ $this->calls[] = sprintf('delete %s of %s', $storedValue->propertyName->value, $storedValue->assetReference->assetId);
+ }
+ return count($storedValues);
+ }
+ );
+
+ $this->metaDataManager = new MetaDataManager($dimensions, PropertyDefinitionsFixture::default(), $this->storage);
+ $this->metaDataRepair = new MetaDataRepair($this->metaDataManager, $dimensions, $this->storage);
+ }
+
+ /**
+ * @test
+ */
+ public function consistentDataNeedsNoRepair(): void
+ {
+ $this->storageContains(
+ self::localizedValue($this->asset, 'caption', $this->en, 'A cat'),
+ self::globalValue($this->asset, 'copyright', '© Acme'),
+ );
+
+ self::assertSame([], $this->metaDataRepair->analyze());
+ }
+
+ /**
+ * @test
+ */
+ public function localizedValuesOfAGlobalPropertyAreConsolidatedIntoTheDefaultChainWinner(): void
+ {
+ $this->storageContains(
+ self::localizedValue($this->asset, 'copyright', $this->de, '© Acme'),
+ self::localizedValue($this->asset, 'copyright', $this->en, '© Acme Inc'),
+ );
+
+ $actions = $this->metaDataRepair->analyze();
+ $promotions = self::actionsOfType($actions, MetaDataRepairActionType::promoteToGlobalScope);
+ self::assertCount(1, $promotions);
+ self::assertSame('© Acme Inc', $promotions[0]->storedValue->value, 'the value of the default dimension wins');
+ self::assertCount(2, self::actionsOfType($actions, MetaDataRepairActionType::deleteWrongScope));
+
+ self::assertSame(2, $this->metaDataRepair->apply($actions));
+ self::assertSame(
+ [
+ 'set copyright of some-asset to "© Acme Inc" in global',
+ 'delete copyright of some-asset',
+ 'delete copyright of some-asset',
+ ],
+ $this->calls,
+ 'the value is promoted before the rows it came from are deleted',
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function theOnlyLocalizedValueOfAGlobalPropertyIsKeptEvenIfItIsNotOnTheDefaultChain(): void
+ {
+ $this->storageContains(self::localizedValue($this->asset, 'copyright', $this->fr, '© Foto Meier'));
+
+ $this->metaDataRepair->apply($this->metaDataRepair->analyze());
+
+ self::assertContains('set copyright of some-asset to "© Foto Meier" in global', $this->calls);
+ }
+
+ /**
+ * @test
+ */
+ public function anExistingSharedValueWinsOverStaleLocalizedOnes(): void
+ {
+ $this->storageContains(
+ self::globalValue($this->asset, 'copyright', '© Current'),
+ self::localizedValue($this->asset, 'copyright', $this->en, '© Stale'),
+ );
+
+ $actions = $this->metaDataRepair->analyze();
+ self::assertSame([], self::actionsOfType($actions, MetaDataRepairActionType::promoteToGlobalScope), 'live data must not be overwritten');
+
+ self::assertSame(1, $this->metaDataRepair->apply($actions));
+ self::assertSame(['delete copyright of some-asset'], $this->calls, 'only the stale row is removed');
+ }
+
+ /**
+ * @test
+ */
+ public function aSharedValueOfALocalizedPropertyIsPromotedToTheDefaultDimension(): void
+ {
+ $this->storageContains(self::globalValue($this->asset, 'caption', 'A cat'));
+
+ $actions = $this->metaDataRepair->analyze();
+ self::assertCount(1, self::actionsOfType($actions, MetaDataRepairActionType::promoteToDefaultDimension));
+
+ $this->metaDataRepair->apply($actions);
+ self::assertSame(
+ ['set caption of some-asset to "A cat" in en', 'delete caption of some-asset'],
+ $this->calls,
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function aSharedValueIsNotPromotedIfTheDefaultDimensionAlreadyHasAValue(): void
+ {
+ $this->storageContains(
+ self::localizedValue($this->asset, 'caption', $this->en, 'A cat'),
+ self::globalValue($this->asset, 'caption', 'Stale'),
+ );
+
+ $actions = $this->metaDataRepair->analyze();
+ self::assertSame([], self::actionsOfType($actions, MetaDataRepairActionType::promoteToDefaultDimension));
+
+ $this->metaDataRepair->apply($actions);
+ self::assertSame(['delete caption of some-asset'], $this->calls);
+ }
+
+ /**
+ * @test
+ */
+ public function valuesOfUnconfiguredDimensionsAreOnlyRemovedWhenPruning(): void
+ {
+ $this->storageContains(self::localizedValue($this->asset, 'caption', self::language('es'), 'Un gato'));
+
+ $actions = $this->metaDataRepair->analyze();
+ self::assertCount(1, self::actionsOfType($actions, MetaDataRepairActionType::deleteObsoleteDimension));
+
+ self::assertSame(0, $this->metaDataRepair->apply($actions));
+ self::assertSame([], $this->calls, 'nothing is touched without pruning');
+
+ self::assertSame(1, $this->metaDataRepair->apply($actions, prune: true));
+ self::assertSame(['delete caption of some-asset'], $this->calls);
+ }
+
+ /**
+ * @test
+ */
+ public function valuesOfUndefinedPropertiesAreOnlyRemovedWhenPruning(): void
+ {
+ $this->storageContains(self::localizedValue($this->asset, 'formerProperty', $this->en, 'obsolete'));
+
+ $actions = $this->metaDataRepair->analyze();
+ self::assertCount(1, self::actionsOfType($actions, MetaDataRepairActionType::deleteUndefinedProperty));
+
+ self::assertSame(0, $this->metaDataRepair->apply($actions));
+ self::assertSame(1, $this->metaDataRepair->apply($actions, prune: true));
+ self::assertSame(['delete formerProperty of some-asset'], $this->calls);
+ }
+
+ /**
+ * @test
+ */
+ public function valuesOfOtherAssetsAreNotAffected(): void
+ {
+ $otherAsset = MetaDataAssetReference::create('neos', 'other-asset');
+ $this->storageContains(
+ self::localizedValue($this->asset, 'copyright', $this->en, '© Acme'),
+ self::localizedValue($otherAsset, 'caption', $this->en, 'A cat'),
+ );
+
+ $this->metaDataRepair->apply($this->metaDataRepair->analyze());
+
+ foreach ($this->calls as $call) {
+ self::assertStringNotContainsString('other-asset', $call);
+ }
+ }
+
+ /**
+ * Values of the same property but of different assets must not be consolidated into one another
+ *
+ * @test
+ */
+ public function eachAssetIsRepairedOnItsOwn(): void
+ {
+ $otherAsset = MetaDataAssetReference::create('neos', 'other-asset');
+ $this->storageContains(
+ self::localizedValue($this->asset, 'copyright', $this->en, '© Acme'),
+ self::localizedValue($otherAsset, 'copyright', $this->en, '© Other'),
+ );
+
+ $promotions = self::actionsOfType($this->metaDataRepair->analyze(), MetaDataRepairActionType::promoteToGlobalScope);
+
+ self::assertCount(2, $promotions);
+ self::assertSame(
+ ['© Acme', '© Other'],
+ array_map(static fn (MetaDataRepairAction $action) => $action->storedValue->value, $promotions),
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function pruningIsRefusedWithoutConfiguredDimensions(): void
+ {
+ $dimensions = $this->createEmptyDimensions();
+ $metaDataRepair = new MetaDataRepair(
+ new MetaDataManager($dimensions, PropertyDefinitionsFixture::default(), $this->storage),
+ $dimensions,
+ $this->storage,
+ );
+
+ self::assertFalse($metaDataRepair->hasConfiguredDimensions());
+ self::assertTrue($this->metaDataRepair->hasConfiguredDimensions());
+ }
+
+ /**
+ * @test
+ */
+ public function repairingIsSupportedByStoragesImplementingTheMaintenanceInterface(): void
+ {
+ self::assertTrue($this->metaDataRepair->isSupported());
+ }
+
+ /**
+ * @test
+ */
+ public function repairingIsUnsupportedByStoragesNotImplementingTheMaintenanceInterface(): void
+ {
+ self::assertFalse($this->repairWithPlainStorage()->isSupported());
+ }
+
+ /**
+ * @test
+ */
+ public function applyingWithAStorageThatCannotBeMaintainedThrows(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionCode(1776280001);
+ $this->repairWithPlainStorage()->apply([]);
+ }
+
+ // -----------------------
+
+ private function repairWithPlainStorage(): MetaDataRepair
+ {
+ $dimensions = $this->createLanguageDimensions();
+ $storage = $this->createMock(MetaDataStorage::class);
+ return new MetaDataRepair(
+ new MetaDataManager($dimensions, PropertyDefinitionsFixture::default(), $storage),
+ $dimensions,
+ $storage,
+ );
+ }
+
+ private function storageContains(MetaDataStoredValue ...$storedValues): void
+ {
+ $this->storage->method('findAllStoredValues')->willReturn($storedValues);
+ }
+
+ private static function localizedValue(MetaDataAssetReference $assetReference, string $propertyName, MetaDataDimensionSpacePoint $dimensionSpacePoint, string $value): MetaDataStoredValue
+ {
+ return new MetaDataStoredValue($assetReference, MetaDataPropertyName::fromString($propertyName), $dimensionSpacePoint->hash, false, $value);
+ }
+
+ private static function globalValue(MetaDataAssetReference $assetReference, string $propertyName, string $value): MetaDataStoredValue
+ {
+ return new MetaDataStoredValue($assetReference, MetaDataPropertyName::fromString($propertyName), 'global', true, $value);
+ }
+
+ /**
+ * @param list $actions
+ * @return list
+ */
+ private static function actionsOfType(array $actions, MetaDataRepairActionType $type): array
+ {
+ return array_values(array_filter($actions, static fn (MetaDataRepairAction $action) => $action->type === $type));
+ }
+}
diff --git a/Tests/Unit/MetaDataManagerTest.php b/Tests/Unit/MetaDataManagerTest.php
new file mode 100644
index 0000000..016bec3
--- /dev/null
+++ b/Tests/Unit/MetaDataManagerTest.php
@@ -0,0 +1,661 @@
+de = self::language('de');
+ $this->en = self::language('en');
+ $this->fr = self::language('fr');
+ $this->es = self::language('es');
+
+ $this->storage = $this->createMock(MetaDataStorage::class);
+ $this->dimensionSpacePointProvider = $this->createLanguageDimensions();
+ $this->metaDataManager = $this->managerFor(PropertyDefinitionsFixture::default());
+ $this->asset = MetaDataAssetReference::create('neos', 'some-asset');
+ }
+
+ // ----------------------- reading
+
+ /**
+ * @test
+ */
+ public function localizedValueWithoutFallbackIsItsOwnValue(): void
+ {
+ $this->storageContains(['caption' => [$this->de->hash => 'Eine Katze']]);
+
+ $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de);
+ self::assertSame('Eine Katze', $value->value);
+ self::assertSame('Eine Katze', $value->ownValue);
+ self::assertNull($value->inheritedValue);
+ self::assertNull($value->inheritedFrom);
+ self::assertTrue($value->hasOwnValue());
+ self::assertFalse($value->isInherited());
+ }
+
+ /**
+ * @test
+ */
+ public function localizedValueFallsBackToTheFallbackDimension(): void
+ {
+ $this->storageContains(['caption' => [$this->en->hash => 'A cat']]);
+
+ $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de);
+ self::assertSame('A cat', $value->value);
+ self::assertNull($value->ownValue, 'the editing use case must not see the fallback value');
+ self::assertSame('A cat', $value->inheritedValue);
+ self::assertTrue($value->inheritedFrom?->equals($this->en));
+ self::assertTrue($value->isInherited());
+ }
+
+ /**
+ * @test
+ */
+ public function ownAndInheritedValueAreReturnedSideBySide(): void
+ {
+ $this->storageContains(['caption' => [$this->en->hash => 'A cat', $this->de->hash => 'Eine Katze']]);
+
+ $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de);
+ self::assertSame('Eine Katze', $value->value, 'the own value wins');
+ self::assertSame('Eine Katze', $value->ownValue);
+ self::assertSame('A cat', $value->inheritedValue, 'the translation hint is available even though the value is overridden');
+ self::assertTrue($value->inheritedFrom?->equals($this->en));
+ self::assertFalse($value->isInherited());
+ }
+
+ /**
+ * @test
+ */
+ public function onlyTheClosestFallbackIsInherited(): void
+ {
+ $this->storageContains(['caption' => [$this->en->hash => 'A cat', $this->fr->hash => 'Un chat']]);
+
+ $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de);
+ self::assertSame('A cat', $value->inheritedValue);
+ self::assertTrue($value->inheritedFrom?->equals($this->en), 'French is not on the German fallback chain');
+ }
+
+ /**
+ * The candidates are looked up in one go, so the manager must not rely on the storage to return
+ * them in the order of the chain
+ *
+ * @test
+ */
+ public function resolutionDoesNotDependOnTheOrderTheStorageReturnsValuesIn(): void
+ {
+ $this->storageContains(['caption' => [$this->de->hash => 'Eine Katze', $this->en->hash => 'A cat']]);
+
+ $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de);
+ self::assertSame('Eine Katze', $value->ownValue);
+ self::assertSame('A cat', $value->inheritedValue);
+ }
+
+ /**
+ * @test
+ */
+ public function valuesOfUnrelatedDimensionsAreNotInherited(): void
+ {
+ $this->storageContains(['caption' => [$this->fr->hash => 'Un chat']]);
+
+ $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de);
+ self::assertNull($value->value);
+ self::assertNull($value->ownValue);
+ self::assertNull($value->inheritedValue);
+ }
+
+ /**
+ * @test
+ */
+ public function missingValuesResolveToEmpty(): void
+ {
+ $this->storageContains([]);
+
+ $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de);
+ self::assertNull($value->value);
+ self::assertNull($value->ownValue);
+ self::assertNull($value->inheritedValue);
+ self::assertNull($value->inheritedFrom);
+ self::assertFalse($value->hasOwnValue());
+ self::assertFalse($value->isInherited());
+ }
+
+ /**
+ * @test
+ */
+ public function omittedDimensionSpacePointRefersToTheDefaultOne(): void
+ {
+ $this->storageContains(['caption' => [$this->en->hash => 'A cat']]);
+
+ self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption')->ownValue);
+ }
+
+ /**
+ * @test
+ */
+ public function localizedValuesAreLookedUpAlongTheWholeChain(): void
+ {
+ $this->storage->expects(self::once())
+ ->method('getMetaDataPropertyValues')
+ ->with(
+ $this->asset,
+ self::callback(static fn (MetaDataPropertyName $name) => $name->equals('caption')),
+ self::callback(fn (MetaDataDimensionSpacePoints $scope) => self::hashesOf($scope) === [$this->de->hash, $this->en->hash]),
+ )
+ ->willReturn([]);
+
+ $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de);
+ }
+
+ /**
+ * @test
+ */
+ public function allDefinedPropertiesArePresentInTheResult(): void
+ {
+ $this->storageContains([]);
+
+ self::assertSame(
+ ['copyright' => null, 'caption' => null],
+ $this->metaDataManager->getMetaDataPropertyValues($this->asset, $this->de)->toArray(),
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function readingAnUndefinedPropertyThrows(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionCode(1776278047);
+ $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'unknown', $this->de);
+ }
+
+ // ----------------------- global scope
+
+ /**
+ * @test
+ */
+ public function globalValuesAreLookedUpInTheGlobalScope(): void
+ {
+ $this->storage->expects(self::once())
+ ->method('getMetaDataPropertyValues')
+ ->with($this->asset, self::anything(), self::isInstanceOf(MetaDataGlobalScope::class))
+ ->willReturn(['global' => '© Acme']);
+
+ self::assertSame('© Acme', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright', $this->de)->value);
+ }
+
+ /**
+ * @test
+ */
+ public function globalValueIsSharedByAllDimensions(): void
+ {
+ $this->storageContains(['copyright' => ['global' => '© Acme']]);
+
+ foreach ([$this->de, $this->en, $this->fr, null] as $dimensionSpacePoint) {
+ $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright', $dimensionSpacePoint);
+ self::assertSame('© Acme', $value->value);
+ self::assertSame('© Acme', $value->ownValue);
+ }
+ }
+
+ /**
+ * @test
+ */
+ public function globalValueIsNeverInherited(): void
+ {
+ $this->storageContains(['copyright' => ['global' => '© Acme']]);
+
+ $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright', $this->de);
+ self::assertSame('© Acme', $value->ownValue, 'a shared value is always an own value');
+ self::assertNull($value->inheritedValue, 'a shared value has nothing to inherit from');
+ self::assertNull($value->inheritedFrom);
+ self::assertFalse($value->isInherited());
+ }
+
+ /**
+ * @test
+ */
+ public function readingAGlobalValueAcceptsAnyDimensionSpacePoint(): void
+ {
+ $this->storageContains(['copyright' => ['global' => '© Acme']]);
+
+ self::assertSame('© Acme', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright', $this->es)->value);
+ }
+
+ // ----------------------- writing
+
+ /**
+ * @test
+ */
+ public function settingALocalizedValueWritesItToTheGivenDimension(): void
+ {
+ $this->storage->expects(self::once())
+ ->method('setMetaDataPropertyValue')
+ ->with(
+ $this->asset,
+ self::callback(static fn (MetaDataPropertyName $name) => $name->equals('caption')),
+ 'Eine Katze',
+ self::callback(fn (MetaDataDimensionSpacePoint $scope) => $scope->equals($this->de)),
+ );
+
+ $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de);
+ }
+
+ /**
+ * @test
+ */
+ public function settingAValueWithoutADimensionSpacePointWritesItToTheDefaultOne(): void
+ {
+ $this->storage->expects(self::once())
+ ->method('setMetaDataPropertyValue')
+ ->with(self::anything(), self::anything(), self::anything(), self::callback(fn (MetaDataDimensionSpacePoint $scope) => $scope->equals($this->en)));
+
+ $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat');
+ }
+
+ /**
+ * @test
+ */
+ public function settingAGlobalValueWritesItToTheGlobalScope(): void
+ {
+ $this->storage->expects(self::once())
+ ->method('setMetaDataPropertyValue')
+ ->with(self::anything(), self::anything(), '© Acme', self::isInstanceOf(MetaDataGlobalScope::class));
+
+ $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->de);
+ }
+
+ /**
+ * The dimension space point is ignored for global properties, so it is not validated either
+ *
+ * @test
+ */
+ public function writingAGlobalValueAcceptsAnyDimensionSpacePoint(): void
+ {
+ $this->storage->expects(self::once())
+ ->method('setMetaDataPropertyValue')
+ ->with(self::anything(), self::anything(), self::anything(), self::isInstanceOf(MetaDataGlobalScope::class));
+
+ $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->es);
+ }
+
+ /**
+ * @test
+ */
+ public function unsettingALocalizedValueOnlyAffectsTheGivenDimension(): void
+ {
+ $this->storage->expects(self::once())
+ ->method('unsetMetaDataPropertyValue')
+ ->with($this->asset, self::anything(), self::callback(fn (MetaDataDimensionSpacePoint $scope) => $scope->equals($this->de)));
+
+ $this->metaDataManager->unsetMetaDataPropertyValue($this->asset, 'caption', $this->de);
+ }
+
+ /**
+ * @test
+ */
+ public function unsettingAGlobalValueIgnoresTheDimensionSpacePoint(): void
+ {
+ $this->storage->expects(self::once())
+ ->method('unsetMetaDataPropertyValue')
+ ->with(self::anything(), self::anything(), self::isInstanceOf(MetaDataGlobalScope::class));
+
+ $this->metaDataManager->unsetMetaDataPropertyValue($this->asset, 'copyright', $this->de);
+ }
+
+ /**
+ * @test
+ */
+ public function writingAnUndefinedPropertyThrows(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionCode(1776278047);
+ $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'unknown', 'whatever', $this->de);
+ }
+
+ /**
+ * @test
+ */
+ public function writingAnUnconfiguredDimensionSpacePointThrows(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionCode(1776279083);
+ $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Hola', $this->es);
+ }
+
+ // ----------------------- property types
+
+ /**
+ * @test
+ */
+ public function valuesAreCoercedToTheDefinedTypeBeforeTheyAreStored(): void
+ {
+ $manager = $this->managerFor(PropertyDefinitionsFixture::typed());
+ $written = [];
+ $this->storage->method('setMetaDataPropertyValue')
+ ->willReturnCallback(static function (MetaDataAssetReference $ref, MetaDataPropertyName $name, string|int|bool $value) use (&$written): void {
+ $written[$name->value] = $value;
+ });
+
+ $manager->setMetaDataPropertyValue($this->asset, 'width', '42', $this->de);
+ $manager->setMetaDataPropertyValue($this->asset, 'featured', 'yes', $this->de);
+
+ self::assertSame(['width' => '42', 'featured' => '1'], $written, 'string input from the command line or a form is coerced');
+ }
+
+ /**
+ * @test
+ */
+ public function writingAValueThatDoesNotMatchTheTypeThrows(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionCode(1785715201);
+ $this->managerFor(PropertyDefinitionsFixture::typed())->setMetaDataPropertyValue($this->asset, 'width', 'abc', $this->de);
+ }
+
+ /**
+ * @test
+ */
+ public function storedValuesAreReadBackAsTheDefinedType(): void
+ {
+ $manager = $this->managerFor(PropertyDefinitionsFixture::typed());
+ $this->storageContains([
+ 'width' => [$this->de->hash => '42'],
+ 'featured' => [$this->de->hash => '1'],
+ ]);
+
+ self::assertSame(42, $manager->getMetaDataPropertyValue($this->asset, 'width', $this->de)->value);
+ self::assertTrue($manager->getMetaDataPropertyValue($this->asset, 'featured', $this->de)->value);
+ }
+
+ /**
+ * @test
+ */
+ public function falseAndZeroAreDistinguishableFromAnAbsentValue(): void
+ {
+ $manager = $this->managerFor(PropertyDefinitionsFixture::typed());
+ $this->storageContains([
+ 'width' => [$this->de->hash => '0'],
+ 'featured' => [$this->de->hash => '0'],
+ ]);
+
+ $width = $manager->getMetaDataPropertyValue($this->asset, 'width', $this->de);
+ self::assertSame(0, $width->value);
+ self::assertTrue($width->hasOwnValue());
+
+ $featured = $manager->getMetaDataPropertyValue($this->asset, 'featured', $this->de);
+ self::assertFalse($featured->value);
+ self::assertTrue($featured->hasOwnValue(), 'FALSE is a value, not the absence of one');
+ }
+
+ /**
+ * @test
+ */
+ public function storedValuesThatDoNotMatchTheTypeAreTreatedLikeAbsentOnes(): void
+ {
+ $manager = $this->managerFor(PropertyDefinitionsFixture::typed());
+ $this->storageContains(['width' => [$this->de->hash => 'abc']]);
+
+ $value = $manager->getMetaDataPropertyValue($this->asset, 'width', $this->de);
+ self::assertNull($value->value);
+ self::assertFalse($value->hasOwnValue());
+ }
+
+ /**
+ * @test
+ */
+ public function anUnreadableValueDoesNotShadowAReadableFallback(): void
+ {
+ $manager = $this->managerFor(PropertyDefinitionsFixture::typed());
+ $this->storageContains(['width' => [$this->de->hash => 'abc', $this->en->hash => '42']]);
+
+ $value = $manager->getMetaDataPropertyValue($this->asset, 'width', $this->de);
+ self::assertSame(42, $value->value, 'the English value is still readable');
+ self::assertNull($value->ownValue);
+ self::assertTrue($value->isInherited());
+ }
+
+ /**
+ * @test
+ */
+ public function globalValuesAreCoercedAsWell(): void
+ {
+ $manager = $this->managerFor(PropertyDefinitionsFixture::create(['copyright' => true, 'caption' => false]));
+ $this->storageContains(['copyright' => ['global' => '42']]);
+
+ self::assertSame('42', $manager->getMetaDataPropertyValue($this->asset, 'copyright')->value);
+ }
+
+ // ----------------------- finding assets
+
+ /**
+ * @test
+ */
+ public function findingAssetsSplitsThePropertiesByScopeAndPassesTheOrderedChain(): void
+ {
+ $this->storage->expects(self::once())
+ ->method('findAssets')
+ ->with(
+ 'neos',
+ 'cat',
+ self::callback(static fn (MetaDataPropertyNames $names) => self::valuesOf($names) === ['caption']),
+ self::callback(fn (MetaDataDimensionSpacePoints $chain) => self::hashesOf($chain) === [$this->de->hash, $this->en->hash]),
+ self::callback(static fn (MetaDataPropertyNames $names) => self::valuesOf($names) === ['copyright']),
+ )
+ ->willReturn([]);
+
+ iterator_to_array($this->metaDataManager->findAssets(MetaDataAssetFilter::create(
+ assetSourceId: 'neos',
+ dimensionSpacePoint: $this->de,
+ searchTerm: 'cat',
+ )), false);
+ }
+
+ /**
+ * @test
+ */
+ public function findingAssetsWithoutAPropertyFilterSearchesAllDefinedProperties(): void
+ {
+ $this->storage->expects(self::once())
+ ->method('findAssets')
+ ->with(
+ null,
+ null,
+ self::callback(static fn (MetaDataPropertyNames $names) => self::valuesOf($names) === ['caption']),
+ self::anything(),
+ self::callback(static fn (MetaDataPropertyNames $names) => self::valuesOf($names) === ['copyright']),
+ )
+ ->willReturn([]);
+
+ iterator_to_array($this->metaDataManager->findAssets(MetaDataAssetFilter::create()), false);
+ }
+
+ /**
+ * @test
+ */
+ public function findingAssetsCanBeRestrictedToProperties(): void
+ {
+ $this->storage->expects(self::once())
+ ->method('findAssets')
+ ->with(
+ self::anything(),
+ self::anything(),
+ self::callback(static fn (MetaDataPropertyNames $names) => self::valuesOf($names) === ['caption']),
+ self::anything(),
+ self::callback(static fn (MetaDataPropertyNames $names) => self::valuesOf($names) === []),
+ )
+ ->willReturn([]);
+
+ iterator_to_array($this->metaDataManager->findAssets(MetaDataAssetFilter::create(
+ propertyNames: MetaDataPropertyNames::create('caption'),
+ )), false);
+ }
+
+ /**
+ * @test
+ */
+ public function findingAssetsWithoutADimensionSpacePointUsesTheDefaultOne(): void
+ {
+ $this->storage->expects(self::once())
+ ->method('findAssets')
+ ->with(
+ self::anything(),
+ self::anything(),
+ self::anything(),
+ self::callback(fn (MetaDataDimensionSpacePoints $chain) => self::hashesOf($chain) === [$this->en->hash]),
+ self::anything(),
+ )
+ ->willReturn([]);
+
+ iterator_to_array($this->metaDataManager->findAssets(MetaDataAssetFilter::create()), false);
+ }
+
+ /**
+ * @test
+ */
+ public function findingAssetsReturnsWhatTheStorageFound(): void
+ {
+ $match = MetaDataAssetReference::create('neos', 'some-asset');
+ $this->storage->method('findAssets')->willReturn([$match]);
+
+ self::assertSame([$match], iterator_to_array($this->metaDataManager->findAssets(MetaDataAssetFilter::create()), false));
+ }
+
+ /**
+ * @test
+ */
+ public function findingAssetsForAnUndefinedPropertyThrows(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionCode(1776278047);
+ $this->metaDataManager->findAssets(MetaDataAssetFilter::create(propertyNames: MetaDataPropertyNames::create('unknown')));
+ }
+
+ /**
+ * @test
+ */
+ public function findingAssetsInAnUnconfiguredDimensionSpacePointThrows(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionCode(1776279083);
+ $this->metaDataManager->findAssets(MetaDataAssetFilter::create(dimensionSpacePoint: $this->es));
+ }
+
+ // ----------------------- dimension configuration
+
+ /**
+ * @test
+ */
+ public function theDimensionSpacePointConfigurationIsPassedThrough(): void
+ {
+ self::assertSame(
+ [$this->en->hash, $this->de->hash, $this->fr->hash],
+ self::hashesOf($this->metaDataManager->getDimensionSpacePointConfiguration()),
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function resultShapeDoesNotDependOnTheDimensionConfiguration(): void
+ {
+ $this->dimensionSpacePointProvider = $this->createEmptyDimensions();
+ $manager = $this->managerFor(PropertyDefinitionsFixture::default());
+ $this->storageContains([]);
+
+ self::assertSame(['copyright' => null, 'caption' => null], $manager->getMetaDataPropertyValues($this->asset)->toArray());
+ }
+
+ /**
+ * @test
+ */
+ public function thePropertyDefinitionsArePassedThrough(): void
+ {
+ $definitions = PropertyDefinitionsFixture::typed();
+
+ self::assertSame($definitions, $this->managerFor($definitions)->getPropertyDefinitions());
+ }
+
+ // -----------------------
+
+ private function managerFor(MetaDataPropertyDefinitions $propertyDefinitions): MetaDataManager
+ {
+ return new MetaDataManager($this->dimensionSpacePointProvider, $propertyDefinitions, $this->storage);
+ }
+
+ /**
+ * Declares the values the storage holds, by property name and dimension hash. Global values are
+ * keyed by the literal "global", as the storage does.
+ *
+ * @param array> $valuesByPropertyName
+ */
+ private function storageContains(array $valuesByPropertyName): void
+ {
+ $this->storage->method('getMetaDataPropertyValues')->willReturnCallback(
+ static function (MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoints|MetaDataGlobalScope $scope) use ($valuesByPropertyName): array {
+ $values = $valuesByPropertyName[$propertyName->value] ?? [];
+ $requestedHashes = $scope instanceof MetaDataGlobalScope
+ ? ['global']
+ : self::hashesOf($scope);
+ return array_intersect_key($values, array_flip($requestedHashes));
+ }
+ );
+ }
+
+ /**
+ * @return list
+ */
+ private static function hashesOf(MetaDataDimensionSpacePoints $dimensionSpacePoints): array
+ {
+ return $dimensionSpacePoints->map(static fn (MetaDataDimensionSpacePoint $dimensionSpacePoint) => $dimensionSpacePoint->hash);
+ }
+
+ /**
+ * @return list
+ */
+ private static function valuesOf(MetaDataPropertyNames $propertyNames): array
+ {
+ return $propertyNames->map(static fn (MetaDataPropertyName $propertyName) => $propertyName->value);
+ }
+}
diff --git a/composer.json b/composer.json
index 6cf0691..1f761e8 100644
--- a/composer.json
+++ b/composer.json
@@ -1,95 +1,45 @@
{
"name": "neos/metadata",
- "description": "Data types and interfaces to manage meta data for assets in Neos",
+ "description": "Data types and interfaces to manage extensible meta data for assets in Neos",
"type": "neos-package",
"license": "MIT",
"require": {
- "doctrine/collections": "^1.0",
- "neos/media": "^3.0 || ^4.0 || ^5.0 || ^7.0 || ^8.0 || dev-master",
- "neos/flow": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || dev-master"
+ "php": "^8.4",
+ "neos/media": "^8.3 || ^8.4 || ^9.0",
+ "webmozart/assert": "^1 || ^2",
+ "doctrine/dbal": "*"
+ },
+ "suggest": {
+ "neos/content-repository": "^8.3"
+ },
+ "require-dev": {
+ "neos/content-repository": "^8.3",
+ "phpunit/phpunit": "^11.0",
+ "phpstan/phpstan": "^1.12"
},
"autoload": {
"psr-4": {
"Neos\\MetaData\\": "Classes"
}
},
+ "autoload-dev": {
+ "psr-4": {
+ "Neos\\MetaData\\Tests\\": "Tests"
+ }
+ },
+ "scripts": {
+ "test": "../../../bin/phpunit --enforce-time-limit --bootstrap ../../Libraries/autoload.php --testdox Tests",
+ "test:ci": "phpunit --enforce-time-limit --bootstrap vendor/autoload.php --testdox Tests",
+ "codestyle": "phpstan analyse --autoload-file ../../Libraries/autoload.php -c phpstan.neon"
+ },
+ "config": {
+ "allow-plugins": {
+ "neos/composer-plugin": true
+ }
+ },
"extra": {
"neos": {
"package-key": "Neos.MetaData"
- },
- "applied-flow-migrations": [
- "TYPO3.FLOW3-201201261636",
- "TYPO3.Fluid-201205031303",
- "TYPO3.FLOW3-201205292145",
- "TYPO3.FLOW3-201206271128",
- "TYPO3.FLOW3-201209201112",
- "TYPO3.Flow-201209251426",
- "TYPO3.Flow-201211151101",
- "TYPO3.Flow-201212051340",
- "TYPO3.TypoScript-130516234520",
- "TYPO3.TypoScript-130516235550",
- "TYPO3.TYPO3CR-130523180140",
- "TYPO3.Neos.NodeTypes-201309111655",
- "TYPO3.Flow-201310031523",
- "TYPO3.Flow-201405111147",
- "TYPO3.Neos-201407061038",
- "TYPO3.Neos-201409071922",
- "TYPO3.TYPO3CR-140911160326",
- "TYPO3.Neos-201410010000",
- "TYPO3.TYPO3CR-141101082142",
- "TYPO3.Neos-20141113115300",
- "TYPO3.Fluid-20141113120800",
- "TYPO3.Flow-20141113121400",
- "TYPO3.Fluid-20141121091700",
- "TYPO3.Neos-20141218134700",
- "TYPO3.Fluid-20150214130800",
- "TYPO3.Neos-20150303231600",
- "TYPO3.TYPO3CR-20150510103823",
- "TYPO3.Flow-20151113161300",
- "TYPO3.Form-20160601101500",
- "TYPO3.Flow-20161115140400",
- "TYPO3.Flow-20161115140430",
- "Neos.Flow-20161124204700",
- "Neos.Flow-20161124204701",
- "Neos.Twitter.Bootstrap-20161124204912",
- "Neos.Form-20161124205254",
- "Neos.Flow-20161124224015",
- "Neos.Party-20161124225257",
- "Neos.Eel-20161124230101",
- "Neos.Kickstart-20161124230102",
- "Neos.Setup-20161124230842",
- "Neos.Imagine-20161124231742",
- "Neos.Media-20161124233100",
- "Neos.NodeTypes-20161125002300",
- "Neos.SiteKickstarter-20161125002311",
- "Neos.Neos-20161125002322",
- "Neos.ContentRepository-20161125012000",
- "Neos.Fusion-20161125013710",
- "Neos.Setup-20161125014759",
- "Neos.SiteKickstarter-20161125095901",
- "Neos.Fusion-20161125104701",
- "Neos.NodeTypes-20161125104800",
- "Neos.Neos-20161125104802",
- "Neos.Kickstarter-20161125110814",
- "Neos.Neos-20161125122412",
- "Neos.Flow-20161125124112",
- "TYPO3.FluidAdaptor-20161130112935",
- "Neos.Fusion-20161201202543",
- "Neos.Neos-20161201222211",
- "Neos.Fusion-20161202215034",
- "Neos.Fusion-20161219092345",
- "Neos.ContentRepository-20161219093512",
- "Neos.Media-20161219094126",
- "Neos.Neos-20161219094403",
- "Neos.Neos-20161219122512",
- "Neos.Fusion-20161219130100",
- "Neos.Neos-20161220163741",
- "Neos.SwiftMailer-20161130105617",
- "Neos.Neos-20170115114620",
- "Neos.Fusion-20170120013047",
- "Neos.Flow-20170125103800",
- "Neos.Seo-20170127154600",
- "Neos.Flow-20170127183102"
- ]
+ }
}
}
diff --git a/phpstan.ci.neon b/phpstan.ci.neon
new file mode 100644
index 0000000..12b773c
--- /dev/null
+++ b/phpstan.ci.neon
@@ -0,0 +1,8 @@
+parameters:
+ level: max
+ phpVersion: 80400
+ paths:
+ - Classes
+ - Tests
+ scanDirectories:
+ - Packages/Framework/Neos.Flow/Tests
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..fbc72d1
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,8 @@
+parameters:
+ level: max
+ phpVersion: 80400
+ paths:
+ - Classes
+ - Tests
+ scanDirectories:
+ - ../../Framework/Neos.Flow/Tests