Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
130 changes: 122 additions & 8 deletions Classes/Command/AssetMetaDataCommandController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@
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();
Expand All @@ -25,6 +29,9 @@ public function __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
Expand All @@ -41,9 +48,9 @@ public function setCommand(string $assetId, string $property, string $value, str
$value,
$dimensionSpacePointDecoded,
);
$message = sprintf('Metadata property "%s" of asset "%s" was set to "%s"', $property, $value, $assetId);
$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->hash);
$message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded);
}
$this->outputLine("<success>$message</success>");
}
Expand All @@ -67,15 +74,17 @@ public function unsetCommand(string $assetId, string $property, string|null $ass
);
$message = sprintf('Metadata property "%s" of asset "%s" was unset', $property, $assetId);
if ($dimensionSpacePointDecoded !== null) {
$message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded->hash);
$message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded);
}
$this->outputLine("<success>$message</success>");
}

/**
* Lists all metadata properties for an asset
*
* @param string $assetId ID of the asset to unset the metadata property for
* 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
*/
Expand All @@ -89,12 +98,117 @@ public function listCommand(string $assetId, string|null $assetSource = null, st
);
$message = sprintf('Metadata properties of asset "%s"', $assetId);
if ($dimensionSpacePointDecoded !== null) {
$message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded->hash);
$message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded);
}
$message .= ':';
$this->outputLine($message);
$this->outputLine($message . ':');
foreach ($metaDataPropertyValues as $propertyName => $propertyValue) {
$this->outputLine(' <b>%s:</b> %s', [$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>');
}
}

Expand Down
58 changes: 58 additions & 0 deletions Classes/Domain/Dto/MetaDataAssetFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace Neos\MetaData\Domain\Dto;

/**
* Criteria for {@see MetaDataManager::findAssets()}. Every criterion is optional and criteria are
* combined with AND.
*
* Note that an omitted {@see self::$dimensionSpacePoint} means the *default* dimension space point, as
* everywhere else in this package – not "any dimension". A search is always carried out as seen from
* one dimension space point, so that it returns what an editor working in that dimension actually sees.
*/
final readonly class MetaDataAssetFilter
{
/**
* @param string|null $assetSourceId NULL matches assets of every asset source
* @param MetaDataDimensionSpacePoint|null $dimensionSpacePoint the dimension space point the values are resolved for, NULL = the default one
* @param string|null $searchTerm NULL matches every asset that has a value for the filtered properties at all
* @param MetaDataPropertyNames|null $propertyNames the properties to search in, NULL = all defined ones
*/
private function __construct(
public ?string $assetSourceId,
public ?MetaDataDimensionSpacePoint $dimensionSpacePoint,
public ?string $searchTerm,
public ?MetaDataPropertyNames $propertyNames,
) {
}

public static function create(
?string $assetSourceId = null,
?MetaDataDimensionSpacePoint $dimensionSpacePoint = null,
?string $searchTerm = null,
?MetaDataPropertyNames $propertyNames = null,
): self {
return new self(
$assetSourceId,
$dimensionSpacePoint,
self::normalizeSearchTerm($searchTerm),
$propertyNames,
);
}

/**
* A search term that is empty or consists of whitespace only is treated like an omitted one, so
* that clearing a search field behaves like not having searched rather than like searching for
* nothing.
*/
private static function normalizeSearchTerm(?string $searchTerm): ?string
{
if ($searchTerm === null) {
return null;
}
$trimmed = trim($searchTerm);
return $trimmed === '' ? null : $trimmed;
}
}
31 changes: 31 additions & 0 deletions Classes/Domain/Dto/MetaDataGlobalScope.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Neos\MetaData\Domain\Dto;

use Stringable;

/**
* Marker for values of properties with a global scope {@see MetaDataPropertyDefinition::$globalScope}.
*
* Such values are shared by all dimensions, so no {@see MetaDataDimensionSpacePoint} applies to them.
* This marker is substituted by the {@see MetaDataManager} on behalf of a global property – it is never
* passed in by a caller.
*/
final readonly class MetaDataGlobalScope implements Stringable
{
private function __construct()
{
}

public static function create(): self
{
return new self();
}

public function __toString(): string
{
return 'global';
}
}
77 changes: 77 additions & 0 deletions Classes/Domain/Dto/MetaDataPropertyNames.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace Neos\MetaData\Domain\Dto;

use Closure;
use Countable;
use IteratorAggregate;
use Traversable;

/**
* A set of {@see MetaDataPropertyName}s, e.g. the properties a search is restricted to
* {@see MetaDataAssetFilter::$propertyNames}
*
* @implements IteratorAggregate<MetaDataPropertyName>
*/
final readonly class MetaDataPropertyNames implements IteratorAggregate, Countable
{
/**
* @param list<MetaDataPropertyName> $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);
}
}
Loading