Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
edc21d6
!!! FEATURE: Extensible meta data properties
bwaidelich Apr 15, 2026
93294c7
Add `MetaDataManager::getMetaDataPropertyValues()`
bwaidelich Apr 15, 2026
c1debab
Respect asset source id
bwaidelich Apr 16, 2026
8fb2e4e
Split `MetaDataConfiguration` into `MetaDataPropertyDefinitions` and …
bwaidelich Apr 17, 2026
e7bd6e2
Apply suggestion from @bwaidelich
bwaidelich Apr 17, 2026
6f06c16
FIX: MetaDataDimensionSpacePointSet parsing
c4ll-m3-j4ck Apr 16, 2026
8d4df59
WIP: TASK: Add translations
c4ll-m3-j4ck Apr 16, 2026
e902ffb
FEATURE: Add AssetMetaData eel-helper
c4ll-m3-j4ck Apr 16, 2026
8d03077
FEATURE: Add migration for existing metadata from Asset
c4ll-m3-j4ck Apr 16, 2026
d81f183
FIX: Support assetSources in eel-helper, dsp hashing calculation
c4ll-m3-j4ck Apr 17, 2026
c4e076a
FIX: Adapt MigrationCommandController to new Reference interface
c4ll-m3-j4ck Apr 17, 2026
46c2194
FEATURE: Convenience initializer for MetaDataAssetReference
c4ll-m3-j4ck Apr 17, 2026
86a1684
FEATURE: Implement content-repository DSP adapter
c4ll-m3-j4ck Apr 17, 2026
adf9ad7
FEATURE: Implement i18n handling for properties from yaml
c4ll-m3-j4ck Apr 17, 2026
a7520f3
TASK: Replace dsp hash retrieval with generalized map
c4ll-m3-j4ck Apr 20, 2026
40967cb
WIP: Cleanup dimensions fetching from cr
c4ll-m3-j4ck Jun 3, 2026
e3cabbd
TASK: Rename and move createAllPresetCombinations
dlubitz Jun 4, 2026
f9fca9a
TASK: Add VO equals method for comparison
dlubitz Jun 4, 2026
0d37e45
TASK: Allow to fetch values without fallback and to fetch values from…
dlubitz Jun 4, 2026
b3df3e1
TASK: Allow unsetting values without DSP
dlubitz Jun 4, 2026
6df8fca
BUGFIX: Fix migration command
dlubitz Jun 4, 2026
f3719cb
TASK: Allow PHP 8.5
Sebobo Jul 22, 2026
4831e14
Fix doc comments of `MetaDataDimensionSpacePoint`
bwaidelich Jul 31, 2026
ac9d36c
Update README to reflect current version
bwaidelich Jul 31, 2026
7d4c8f1
BUGFIX: Enumerate dimension space points by preset value
bwaidelich Jul 31, 2026
4acb3bc
BUGFIX: Allow metadata properties without `ui` configuration
bwaidelich Jul 31, 2026
516c0c3
!!! FEATURE: Respect `globalScope` of metadata properties
bwaidelich Jul 31, 2026
85ac242
TASK: Cover enumeration of dimension space points by preset value
bwaidelich Jul 31, 2026
ec3cdf2
!!! FEATURE: Allow assets to be found by a metadata filter
bwaidelich Aug 3, 2026
543992c
FEATURE: Add `getMetaDataProperty()` to the Eel helper
bwaidelich Aug 3, 2026
af564ce
FEATURE: Coerce metadata values to the configured property type
bwaidelich Aug 3, 2026
bbeee7a
TASK: Rework the test suite along the seams of the package
bwaidelich Aug 3, 2026
79704fe
TASK: Move two paragraphs back out of the "Property types" section
bwaidelich Aug 3, 2026
fed6ec5
FIX: Add getOwnValue method to MetaDataPropertyValue for fusion access
c4ll-m3-j4ck Aug 4, 2026
802aa88
Merge pull request #15 from c4ll-m3-j4ck/feature/global-scope-metadat…
bwaidelich Aug 4, 2026
5ac2b4b
FEATURE: Skip metadata properties that are configured to `null`
bwaidelich Aug 5, 2026
ea8eba9
Merge branch 'feature/global-scope-metadata-properties' of https://gi…
bwaidelich Aug 5, 2026
59da8b9
FEATURE: Allow nullable ui configuration
c4ll-m3-j4ck Aug 5, 2026
e1c7540
Merge pull request #16 from c4ll-m3-j4ck/feature/global-scope-metadat…
bwaidelich Aug 6, 2026
190a95b
TASK: Add github action to run tests
Sebobo Sep 2, 2026
3026e13
TASK: Bump phpstan to max level and adjust code
Sebobo Sep 2, 2026
8f7421e
TASK: Split settings
Sebobo Sep 3, 2026
cc63a39
TASK: Allow translation shorthand strings for metadata property labels
Sebobo Sep 3, 2026
4835685
TASK: Narrow phpstan version to prevent errors in CI
Sebobo Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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/
235 changes: 235 additions & 0 deletions Classes/Command/AssetMetaDataCommandController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
<?php

declare(strict_types=1);

namespace Neos\MetaData\Command;

use InvalidArgumentException;
use JsonException;
use Neos\Flow\Cli\CommandController;
use Neos\MetaData\Domain\Dto\MetaDataAssetReference;
use Neos\MetaData\Domain\Dto\MetaDataDimensionSpacePoint;
use Neos\MetaData\Domain\Dto\MetaDataPropertyName;
use Neos\MetaData\Maintenance\MetaDataRepair;
use Neos\MetaData\Maintenance\MetaDataRepairAction;
use Neos\MetaData\Maintenance\MetaDataRepairActionType;
use Neos\MetaData\MetaDataManager;

final class AssetMetaDataCommandController extends CommandController
{

public function __construct(
private readonly MetaDataManager $metaDataManager,
private readonly MetaDataRepair $metaDataRepair,
)
{
parent::__construct();
}

/**
* Sets a metadata property for an asset to a specific value
*
* For properties with a global scope the dimension space point is ignored, because such properties
* have a single value that is shared by all dimensions.
*
* @param string $assetId ID of the asset to set the metadata property for
* @param string $property name of the metadata property to set
* @param string $value value of the metadata property
* @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 setCommand(string $assetId, string $property, string $value, string|null $assetSource = null, string|null $dimensionSpacePoint = null): void
{
$dimensionSpacePointDecoded = $dimensionSpacePoint !== null ? self::parseDimensionSpacePoint($dimensionSpacePoint) : null;
$assetReference = MetaDataAssetReference::create($assetSource ?? 'neos', $assetId);
$this->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("<success>$message</success>");
}

/**
* 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("<success>$message</success>");
}

/**
* 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(' <b>%s:</b> %s', $propertyName, $propertyValue->value ?? '-');
if ($propertyValue->isInherited()) {
$line .= sprintf(' <comment>(inherited from %s)</comment>', $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.<name>.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('<error>The configured metadata storage does not support repairing</error>');
$this->quit(1);
}
if ($prune && !$this->metaDataRepair->hasConfiguredDimensions()) {
$this->outputLine('<error>Refusing to prune because no content dimension is configured</error>');
$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('<success>No metadata values need repairing</success>');
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('<comment>Nothing was changed. Re-run with --force to apply.</comment>');
return;
}
$deleted = $this->metaDataRepair->apply($actions, $prune);
$this->outputLine();
$this->outputLine('<success>Repaired metadata values, %d value(s) were removed</success>', [$deleted]);
}

// -----------------------

/**
* @param list<MetaDataRepairAction> $actions
*/
private function outputScopeActions(array $actions): void
{
$scopeActions = array_filter($actions, static fn (MetaDataRepairAction $action) => !$action->type->requiresPrune());
if ($scopeActions === []) {
return;
}
$this->outputLine('<b>Values with a scope that contradicts the property definition:</b>');
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<MetaDataRepairAction> $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('<b>Unreachable values:</b>');
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(' <comment>Re-run with --prune to include them.</comment>');
}
}

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);
}

}
Loading