diff --git a/components/ILIAS/Questions/Legacy/Administration/class.ilObjQuestionsGUI.php b/components/ILIAS/Questions/Legacy/Administration/class.ilObjQuestionsGUI.php index 42d810d1497c..ec30ba26b104 100755 --- a/components/ILIAS/Questions/Legacy/Administration/class.ilObjQuestionsGUI.php +++ b/components/ILIAS/Questions/Legacy/Administration/class.ilObjQuestionsGUI.php @@ -108,6 +108,33 @@ public function executeCommand(): void break; default: + if ($cmd === 'export') { + global $DIC; + $local_dic = LocalDIC::dic(); + + $builder = new \ILIAS\Questions\ExportImport\Foundation\Builder( + $DIC, + $local_dic + ); + + $exporter = new \ILIAS\Questions\ExportImport\QuestionsExporter($builder); + $collector = new \ILIAS\Questions\ExportImport\QuestionsDataCollector( + $local_dic[\ILIAS\Questions\Persistence\Repository::class] + ); + $serializer = new \ILIAS\Questions\ExportImport\Foundation\Serializing\JSONMemorySerializer(); + + $importer = new \ILIAS\Questions\ExportImport\QuestionsImporter( + $builder, + $local_dic[\ILIAS\Questions\Persistence\Repository::class] + ); + $deserializer = new \ILIAS\Questions\ExportImport\Foundation\Serializing\JSONMemoryDeserializer(); + + $exported = $exporter->export($collector, $serializer->open('memory')); + $importer->import($deserializer->open($exported), 320); + + exit(); + } + if ($cmd === null || $cmd === '' || $cmd === 'view') { $cmd = 'viewQuestions'; } diff --git a/components/ILIAS/Questions/src/AnswerForm/Properties.php b/components/ILIAS/Questions/src/AnswerForm/Properties.php index 16c5b5c5a19d..044b1261fb5d 100644 --- a/components/ILIAS/Questions/src/AnswerForm/Properties.php +++ b/components/ILIAS/Questions/src/AnswerForm/Properties.php @@ -20,6 +20,7 @@ namespace ILIAS\Questions\AnswerForm; +use ILIAS\Questions\ExportImport\Foundation\Contracts\Normalizable; use ILIAS\Questions\Persistence\Storable; use ILIAS\Questions\Presentation\Definitions\Environment; use ILIAS\Data\UUID\Uuid; @@ -29,7 +30,7 @@ use ILIAS\UI\Component\Table\Ordering as OrderingTable; use Psr\Http\Message\ServerRequestInterface; -interface Properties extends Storable +interface Properties extends Storable, Normalizable { public function getAnswerFormId(): Uuid; diff --git a/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/ClozeText/Text.php b/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/ClozeText/Text.php index d6e9cfb092b9..f9db47626261 100644 --- a/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/ClozeText/Text.php +++ b/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/ClozeText/Text.php @@ -171,6 +171,31 @@ function (array $matches) use (&$new_gaps): string { return $clone; } + public function replaceGapsWithIds( + array $gaps_to_replace + ): self { + if ($gaps_to_replace === []) { + return $this; + } + + $clone = clone $this; + $clone->cloze_text = $this->text_factory->markdown( + mb_ereg_replace_callback( + '{{' . Gap::GAP_PLACEHOLDER_NAME . '_([0-9a-fA-F-]+)}}', + function (array $matches) use ($gaps_to_replace): string { + $old_id = $matches[1]; + if (!isset($gaps_to_replace[$old_id]) || $gaps_to_replace[$old_id] === null) { + return $matches[0]; + } + + return '{{' . Gap::GAP_PLACEHOLDER_NAME . '_' . $gaps_to_replace[$old_id] . '}}'; + }, + $this->cloze_text->getRawRepresentation() + ) + ); + return $clone; + } + private function hasAtLeastOneGap(): bool { if ($this->cloze_text->getRawRepresentation() === '') { diff --git a/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Combinations/Combinations.php b/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Combinations/Combinations.php index 90c6540c8a3e..66a8eb3ec1e8 100644 --- a/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Combinations/Combinations.php +++ b/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Combinations/Combinations.php @@ -21,16 +21,19 @@ namespace ILIAS\Questions\AnswerFormTypes\Cloze\Properties\Combinations; use ILIAS\Questions\AnswerFormTypes\Cloze\Persistence; +use ILIAS\Questions\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\Questions\ExportImport\Foundation\Contracts\Transformations; use ILIAS\Questions\Persistence\Manipulate; use ILIAS\Questions\Persistence\TableNameBuilder; use ILIAS\Data\UUID\Uuid; use ILIAS\Language\Language; use ILIAS\HTTP\Services as HttpServices; use ILIAS\Refinery\Factory as Refinery; +use ILIAS\Refinery\Transformation; use ILIAS\UI\Factory as UIFactory; use ILIAS\UI\Component\Table\DataRowBuilder; -class Combinations +class Combinations implements Normalizable { private array $combinations; @@ -173,4 +176,16 @@ public function getNumberOfCombinations(): int { return count($this->combinations); } + + public function toNormalized(Transformations $tt): Transformation + { + //TODO + return $tt->custom()->transformation(fn() => []); + } + + public function fromNormalized(Transformations $tt): Transformation + { + //TODO + return $tt->custom()->transformation(fn() => $this); + } } diff --git a/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/AnswerOptions/AnswerOption.php b/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/AnswerOptions/AnswerOption.php index ff0000cdc243..1d0cba4887ba 100644 --- a/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/AnswerOptions/AnswerOption.php +++ b/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/AnswerOptions/AnswerOption.php @@ -20,11 +20,14 @@ namespace ILIAS\Questions\AnswerFormTypes\Cloze\Properties\Gaps\AnswerOptions; +use ILIAS\Questions\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\Questions\ExportImport\Foundation\Contracts\Transformations; use ILIAS\Questions\Persistence\Replace; use ILIAS\Questions\Persistence\Value; use ILIAS\Data\UUID\Uuid; +use ILIAS\Refinery\Transformation; -class AnswerOption +class AnswerOption implements Normalizable { public const string FORM_KEY_ID = 'id'; public const string FORM_KEY_POSITION = 'position'; @@ -166,4 +169,35 @@ private function buildValuesForGapReplace(): array ]; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'answer_option_id' => $tt->normalize($this->answer_option_id), + 'answer_input_id' => $tt->normalize($this->answer_input_id), + 'position' => $this->position, + 'text_value' => $this->text_value, + 'lower_limit' => $this->lower_limit, + 'upper_limit' => $this->upper_limit, + 'available_points' => $this->available_points, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): AnswerOption { + $clone = clone $this; + $clone->text_value = $tt->string($normalized['text_value']); + $clone->lower_limit = $tt->nullableFloat($normalized['lower_limit']); + $clone->upper_limit = $tt->nullableFloat($normalized['upper_limit']); + $clone->available_points = $tt->float($normalized['available_points']); + return $clone; + }); + } } diff --git a/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/AnswerOptions/AnswerOptions.php b/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/AnswerOptions/AnswerOptions.php index 00bf3b3b704a..89fff16f85c3 100644 --- a/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/AnswerOptions/AnswerOptions.php +++ b/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/AnswerOptions/AnswerOptions.php @@ -13,13 +13,15 @@ * us at: * https://www.ilias.de * https://github.com/ILIAS-eLearning - * + *N *********************************************************************/ declare(strict_types=1); namespace ILIAS\Questions\AnswerFormTypes\Cloze\Properties\Gaps\AnswerOptions; +use ILIAS\Questions\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\Questions\ExportImport\Foundation\Contracts\Transformations; use ILIAS\Questions\Persistence\Delete; use ILIAS\Questions\Persistence\Replace; use ILIAS\Questions\Persistence\TableNameBuilder; @@ -32,7 +34,7 @@ use ILIAS\Refinery\Transformation; use ILIAS\UI\Component\Input\Field\Factory as FieldFactory; -class AnswerOptions +class AnswerOptions implements Normalizable { private array $answer_options_awarding_points; @@ -310,4 +312,34 @@ private function retrieveAnswerOptionByTextValue( ); return array_shift($filtered_array) ?? null; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'options' => $tt->normalize($this->answer_options), + ]); + //TODO: answer_options_awarding_points + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): AnswerOptions { + $clone = clone $this; + foreach ($normalized['options'] as $option_data) { + $answer_option = $this->factory->getDefaultAnswerOptionForPosition( + $this->answer_input_id, + $tt->int($option_data['position']) + ); + $clone->answer_options[] = $tt->denormalize($option_data, $answer_option); + } + return $clone; + }); + //TODO: answer_options_awarding_points + } } diff --git a/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/Gap.php b/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/Gap.php index b8ab11b85caa..4d14e6f7330f 100644 --- a/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/Gap.php +++ b/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/Gap.php @@ -23,6 +23,8 @@ use ILIAS\Questions\AnswerForm\Persistence; use ILIAS\Questions\AnswerFormTypes\Cloze\Properties\Gaps\AnswerOptions\AnswerOptions; use ILIAS\Questions\Definitions\TextMatchingOptions; +use ILIAS\Questions\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\Questions\ExportImport\Foundation\Contracts\Transformations; use ILIAS\Questions\Persistence\Replace; use ILIAS\Questions\Persistence\TableNameBuilder; use ILIAS\Questions\Persistence\TableTypes; @@ -38,7 +40,7 @@ use ILIAS\UI\Component\Table\DataRowBuilder; use ILIAS\Refinery\Transformation; -class Gap +class Gap implements Normalizable { public const string GAP_PLACEHOLDER_NAME = 'GAP'; @@ -50,9 +52,6 @@ class Gap private const string FORM_KEY_SHUFFLE_ANSWER_OPTIONS = 'shuffle'; private const string FORM_KEY_ANSWER_OPTIONS = 'answer_options'; - /** - * @param array $answer_options - */ public function __construct( private readonly Uuid $answer_input_id, private readonly Uuid $answer_form_id, @@ -476,4 +475,42 @@ private function getShortenedAnswerInputId(): string { return mb_substr($this->answer_input_id->toString(), 0, 4); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'answer_input_id' => $tt->normalize($this->answer_input_id), + 'answer_form_id' => $tt->normalize($this->answer_form_id), + 'position' => $this->position, + 'type' => $this->type?->getIdentifier(), + 'max_chars' => $this->max_chars, + 'step_size' => $this->step_size, + 'text_matching_method' => $this->text_matching_method?->value, + 'min_autocomplete' => $this->min_autocomplete, + 'shuffle_answer_options' => $this->shuffle_answer_options, + 'answer_options' => $tt->normalize($this->answer_options), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + // Answer Input Id, Answer Form Id, Position, Type will be set in Gaps::fromNormalized + + return $tt->custom()->transformation(function (array $normalized) use ($tt): Gap { + $clone = clone $this; + $clone->max_chars = $tt->nullableInt($normalized['max_chars']); + $clone->step_size = $tt->nullableFloat($normalized['step_size']); + $clone->text_matching_method = $normalized['text_matching_method'] !== null ? TextMatchingOptions::tryFrom($normalized['text_matching_method']) : $this->getTextMatchingMethod(); + $clone->min_autocomplete = $tt->nullableInt($normalized['min_autocomplete']); + $clone->shuffle_answer_options = $tt->nullableBool($normalized['shuffle_answer_options']); + $clone->answer_options = $tt->denormalize($normalized['answer_options'], $this->answer_options); + return $clone; + }); + } } diff --git a/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/Gaps.php b/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/Gaps.php index 20c9305e66a0..f9d219d590f6 100644 --- a/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/Gaps.php +++ b/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Gaps/Gaps.php @@ -20,6 +20,8 @@ namespace ILIAS\Questions\AnswerFormTypes\Cloze\Properties\Gaps; +use ILIAS\Questions\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Questions\ExportImport\Foundation\Contracts\Normalizable; use ILIAS\Questions\Persistence\Delete; use ILIAS\Questions\Persistence\Junctor; use ILIAS\Questions\Persistence\Manipulate; @@ -40,7 +42,7 @@ use ILIAS\UI\Component\Input\Field\MultiSelect; use ILIAS\UI\Component\Table\DataRowBuilder; -class Gaps +class Gaps implements Normalizable { /** * @var array @@ -486,4 +488,29 @@ private function filterGapsBySelected( ARRAY_FILTER_USE_KEY ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => $tt->normalize($this->gaps)); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): Gaps { + $clone = clone $this; + foreach ($normalized as $gap_data) { + $gap = $this->factory->getNewGap($this->answer_form_id, $tt->int($gap_data['position'])) + ->withType($this->factory->getGapTypeByIdentifier($gap_data['type'])); + + $clone = $clone->withGap($tt->denormalize($gap_data, $gap)); + } + return $clone; + }); + } } diff --git a/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Properties.php b/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Properties.php index 6a4fb96cd06f..38a758924080 100644 --- a/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Properties.php +++ b/components/ILIAS/Questions/src/AnswerFormTypes/Cloze/Properties/Properties.php @@ -31,6 +31,7 @@ use ILIAS\Questions\AnswerFormTypes\Cloze\Properties\Definitions\ScoringIdentical; use ILIAS\Questions\AnswerFormTypes\Cloze\Properties\Gaps\Gaps; use ILIAS\Questions\AnswerFormTypes\Cloze\Properties\Gaps\Factory as GapsFactory; +use ILIAS\Questions\ExportImport\Foundation\Contracts\Transformations; use ILIAS\Questions\Persistence\Delete; use ILIAS\Questions\Persistence\Insert; use ILIAS\Questions\Persistence\Update; @@ -45,6 +46,7 @@ use ILIAS\Data\UUID\Uuid; use ILIAS\Language\Language; use ILIAS\Refinery\Factory as Refinery; +use ILIAS\Refinery\Transformation; use ILIAS\UI\Component\Input\Field\Factory as FieldFactory; use ILIAS\UI\Component\Input\Field\Section; use ILIAS\UI\Component\Input\Field\Group; @@ -62,9 +64,6 @@ class Properties implements PropertiesInterface private bool $updated_combinations = false; - /** - * @param array $gaps - */ public function __construct( private readonly Uuid $answer_form_id, private readonly Uuid $question_id, @@ -468,4 +467,30 @@ private function buildDeleteAnswerFormStatement( ] ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize($this->getTypeGenericProperties()), + 'scoring_identical' => $this->scoring_identical->value, + 'gaps' => $tt->normalize($this->gaps), + 'combinations' => $tt->normalize($this->combinations), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation { + // Generic properties will be set in the QuestionImplementation::fromNormalized method + return $tt->custom()->transformation(function (array $normalized) use ($tt): Properties { + $clone = clone $this; + $clone->scoring_identical = ScoringIdentical::from($tt->string($normalized['scoring_identical'])); + $clone->gaps = $tt->denormalize($normalized['gaps'], $this->gaps); + $clone->combinations = $tt->denormalize($normalized['combinations'], $this->combinations); + return $clone; + }); + } } diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Builder.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Builder.php new file mode 100644 index 000000000000..2c2d3a580807 --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Builder.php @@ -0,0 +1,213 @@ + $containers + */ + private array $containers = []; + + public function __construct( + private readonly ILIASContainer $dic, + Container ...$local_containers, + ) { + $this->containers = $local_containers; + } + + /* + Fluent interface methods + */ + + public function withEnableMappings(bool $enable = true): self + { + $clone = clone $this; + $clone->enable_mappings = $enable; + return $clone; + } + + public function withDefaultNormalizers(bool $enable = true): self + { + $clone = clone $this; + $clone->default_normalizers = $enable; + return $clone; + } + + public function withLegacyNormalizers(string $version): self + { + $clone = clone $this; + $clone->legacy_version = $version; + return $clone; + } + + /* + Object creation + */ + + /** + * Create a Transformations instance which was configured by the builder. + */ + + public function create(): TransformationsContract + { + $pipeline = new Pipeline(); + $object = new Transformations( + $this->dic->refinery(), + $pipeline + ); + + if ($this->legacy_version !== null) { + // TODO: Implement semver comparison here? Use ILIAS\Data\Version class? + + $registry = $this->buildRegistry($object, $this->legacy_version); + $pipeline->pipe(new NormalizingPipe($registry)); + $pipeline->pipe(new DenormalizingPipe($registry)); + } + + if ($this->default_normalizers) { + $registry = $this->buildRegistry($object); + $pipeline->pipe(new NormalizingPipe($registry)); + $pipeline->pipe(new DenormalizingPipe($registry)); + } + + if ($this->enable_mappings) { + $pipeline->pipe(new UUIDMappingPipe()); + } + + return $object; + } + + /** + * Register all normalizer classes from the type map artifact by checking for the given version and skipping if + * the normalizer is already registered. + */ + private function buildRegistry(Transformations $object, string $version = NormalizerArtifactObjective::DEFAULT_KEY): Registry + { + $type_map = require NormalizerArtifactObjective::PATH(); + $registry = new Registry(); + + foreach ($type_map as $type => $normalizer_classes) { + if (!isset($normalizer_classes[$version]) || $registry->hasNormalizer($type)) { + continue; + } + + $normalizer = $this->createInstance($normalizer_classes[$version], $object); + $registry->registerNormalizer($type, $normalizer); + } + + return $registry; + } + + /* + Factory & Autowiring + */ + + /** + * Create an instance of a class by resolving the constructor arguments. + * + * @template T of object + * + * @param class-string $class_name + * @param Transformations $transformations + * @return T + */ + private function createInstance(string $class_name, Transformations $transformations): object + { + $reflection_class = new ReflectionClass($class_name); + $constructor = $reflection_class->getConstructor(); + + if ($constructor === null || $constructor->getNumberOfParameters() === 0) { + return $reflection_class->newInstance(); + } + + $arguments = []; + foreach ($constructor->getParameters() as $parameter) { + $arguments[] = $this->resolveConstructorArgument( + $parameter, + $transformations, + $class_name + ); + } + + return $reflection_class->newInstanceArgs($arguments); + } + + /** + * Resolve a constructor argument by trying to resolve it from the global ilias container, the local container or + * the default value. + * + * @throws RuntimeException if the argument cannot be resolved + */ + private function resolveConstructorArgument( + ReflectionParameter $parameter, + Transformations $transformations, + string $class_name + ) { + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + $type = $parameter->getType(); + if ($type instanceof ReflectionNamedType && $type->allowsNull()) { + return null; + } + + + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $type_name = $type->getName(); + + if ($type_name === TransformationsContract::class || in_array(TransformationsContract::class, class_implements($type_name))) { + return $transformations; + } + + foreach ([$this->dic, ...$this->containers] as $container) { + if ($type_name === get_class($container)) { + return $container; + } + } + } + + $name = $parameter->getName(); + throw new RuntimeException("Unable to resolve constructor parameter \${$name} for class {$class_name}."); + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Contracts/DataCollector.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Contracts/DataCollector.php new file mode 100644 index 000000000000..7daf00fb8479 --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Contracts/DataCollector.php @@ -0,0 +1,35 @@ + + * + * @template TValue + * @template TNormalized of null|scalar|NormalizedArray + */ +interface Normalizer +{ + /** + * Converts a value into a normalized form. + * + * @param TValue $value + * @return TNormalized + * + * @throws NormalizingException if the normalizer does not support the given type + */ + public function normalize($value): array|float|bool|int|string|null; + + /** + * Converts a normalized form back into a value. It uses the type hint to determine the expected type, which will be + * returned. + * + * @param TNormalized $normalized + * @param class-string $type + * @return TValue + * + * @throws NormalizingException if the normalizer does not support the given type + */ + public function denormalize(array|float|bool|int|string|null $normalized, string $type); +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Contracts/Pipe.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Contracts/Pipe.php new file mode 100644 index 000000000000..d1e45723ffae --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Contracts/Pipe.php @@ -0,0 +1,39 @@ + $pipes + */ + public function through(array $pipes): self; + + /** + * Push additional pipe onto the pipeline. + * + * @param PipeParam $pipes + */ + public function pipe(\Closure|Pipe $pipe): self; + + /** + * Push additional pipe onto the pipeline which will be executed when the condition is met. + * + * @param \Closure(TPassable): bool $condition + * @param PipeParam $pipe + */ + public function pipeWhen(\Closure $condition, \Closure|Pipe $pipe): self; + + /** + * Push additional pipe onto the pipeline which will be executed when the condition is not met. + * + * @param \Closure(TPassable): bool $condition + * @param PipeParam $pipe + */ + public function pipeUnless(\Closure $condition, \Closure|Pipe $pipe): self; + + /** + * Run the pipeline with a final destination callback. + * + * @param \Closure(TPassable): TPassable $destination + * @return TPassable|mixed + */ + public function then(\Closure $destination): mixed; + + /** + * Run the pipeline and return the result. + * + * @return TPassable + */ + public function thenReturn(): mixed; + + /** + * Set a final callback to be executed after the pipeline ends regardless of the outcome. + * + * @param \Closure(TPassable): TPassable $callback + */ + public function finally(\Closure $callback): self; + + /** + * Get the array of pipes. + * + * @return list + */ + public function pipes(): array; +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Contracts/Serializer.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Contracts/Serializer.php new file mode 100644 index 000000000000..f97e07a21a89 --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Contracts/Serializer.php @@ -0,0 +1,78 @@ +|T $expected + * @return T|null + * + * @throws NormalizingException if the type is not supported + */ + public function denormalize(array|float|bool|int|string|null $normalized, string|object $expected): mixed; + + /* + Transformations + */ + + /** + * Returns a group of transformations that can be used to create custom transformations. + */ + public function custom(): Group; + + /** + * @throws \InvalidArgumentException if the value cannot be transformed into an integer + */ + public function int(mixed $value): int; + + /** + * @throws \InvalidArgumentException if the value cannot be transformed into a float + */ + public function float(mixed $value): float; + + /** + * @throws \InvalidArgumentException if the value cannot be transformed into a string + */ + public function string(mixed $value): string; + + /** + * @throws \InvalidArgumentException if the value cannot be transformed into a boolean + */ + public function bool(mixed $value): bool; + + public function nullableInt(mixed $value): ?int; + + public function nullableFloat(mixed $value): ?float; + + public function nullableString(mixed $value): ?string; + + public function nullableBool(mixed $value): ?bool; +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Attributes/Normalizes.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Attributes/Normalizes.php new file mode 100644 index 000000000000..ebb3733131a9 --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Attributes/Normalizes.php @@ -0,0 +1,44 @@ +types = $types; + } + + /** @var list */ + public array $types; +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Attributes/NormalizesLegacy.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Attributes/NormalizesLegacy.php new file mode 100644 index 000000000000..f5911b6d35ed --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Attributes/NormalizesLegacy.php @@ -0,0 +1,44 @@ +types = $types; + } + + /** @var list */ + public array $types; +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/DateTimeNormalizer.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/DateTimeNormalizer.php new file mode 100644 index 000000000000..ffa087813b7e --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/DateTimeNormalizer.php @@ -0,0 +1,58 @@ + + */ +#[Normalizes(DateTime::class, DateTimeImmutable::class)] +class DateTimeNormalizer implements Normalizer +{ + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof DateTimeImmutable || $value instanceof DateTime) { + return $value->format(DATE_ATOM); + } + + throw new NormalizingException('Invalid datetime value', $value); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): DateTime|DateTimeImmutable + { + return match($type) { + DateTimeImmutable::class => DateTimeImmutable::createFromFormat(DATE_ATOM, $value), + DateTime::class => DateTime::createFromFormat(DATE_ATOM, $value), + default => throw new NormalizingException("Invalid type for datetime: {$type}") + }; + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/IdContainerNormalizer.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/IdContainerNormalizer.php new file mode 100644 index 000000000000..138d92b7ec63 --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/IdContainerNormalizer.php @@ -0,0 +1,68 @@ + + */ +#[Normalizes(IdContainer::class)] +class IdContainerNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof IdContainer) { + if (is_object($value->getId())) { + return [ + 'id' => $this->tt->normalize($value->getId()), + 'type' => get_class($value->getId()), + 'object' => $value->getObject(), + ]; + } else { + return [ + 'id' => (string) $value->getId(), + 'type' => gettype($value->getId()), + 'object' => $value->getObject(), + ]; + } + } + + throw new NormalizingException('Invalid value', $value); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): IdContainer + { + $raw_id = $value['id']; + $type = $value['type']; + + if (class_exists($type)) { + return new IdContainer($this->tt->denormalize($raw_id, $type), $value['object']); + } else { + $id = match($type) { + 'integer' => (int) $raw_id, + 'string' => (string) $raw_id, + 'float' => (float) $raw_id, + 'bool' => (bool) $raw_id, + 'null' => null, + default => throw new NormalizingException("Invalid type for id: {$type}") + }; + return new IdContainer($id, $value['object']); + } + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/Legacy11UUIDNormalizer.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/Legacy11UUIDNormalizer.php new file mode 100644 index 000000000000..5e0feb3a4cf5 --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/Legacy11UUIDNormalizer.php @@ -0,0 +1,57 @@ + normalizer instance. Each type is registered at most once. + * + * @var array + */ + private array $type_map = []; + + /** + * Register a normalizer for a type. + * + * @param class-string $type + * + * @throws NormalizingException if the type is already registered + */ + public function registerNormalizer(string $type, Normalizer $normalizer): void + { + if (isset($this->type_map[$type])) { + $existing = get_class($this->type_map[$type]); + throw new NormalizingException("Type {$type} is already registered to {$existing}"); + } + $this->type_map[$type] = $normalizer; + } + + /** + * Check if a normalizer is registered for a type. + * + * @param class-string $type + */ + public function hasNormalizer(string $type): bool + { + return isset($this->type_map[$type]); + } + + /** + * Return the normalizer that should handle the given type. Resolves to the most specific + * registered type (child classes / implementing classes before parents/interfaces). + * + * @param class-string $type + * @return Normalizer|null null if no normalizer supports this type + */ + public function getNormalizerFor(string $type): ?Normalizer + { + $candidates = $this->findCandidateTypes($type); + if ($candidates === []) { + return null; + } + $mostSpecificType = $this->selectMostSpecificType($type, $candidates); + + return $this->type_map[$mostSpecificType]; + } + + /** + * Types S from registry such that $type is assignable to S (same class or subclass/implementation). + * + * @param class-string $type + * @return list + */ + private function findCandidateTypes(string $type): array + { + $candidates = []; + foreach (array_keys($this->type_map) as $registeredType) { + if ($type === $registeredType || is_subclass_of($type, $registeredType)) { + $candidates[] = $registeredType; + } + } + + return $candidates; + } + + /** + * Among candidate types (all are assignable from $type), return the most specific one + * (the one closest to $type: child classes before parent classes/interfaces). + * + * @param class-string $type + * @param list $candidate_types + * @return class-string + */ + private function selectMostSpecificType(string $type, array $candidate_types): string + { + if (count($candidate_types) === 1) { + return $candidate_types[0]; + } + + usort($candidate_types, function (string $a, string $b): int { + if ($a === $b) { + return 0; + } + if (is_subclass_of($a, $b)) { + return -1; + } + if (is_subclass_of($b, $a)) { + return 1; + } + return 0; + }); + + return $candidate_types[0]; + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/TypeGenericPropertiesNormalizer.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/TypeGenericPropertiesNormalizer.php new file mode 100644 index 000000000000..b77e05fc392c --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/TypeGenericPropertiesNormalizer.php @@ -0,0 +1,100 @@ + + */ +#[Normalizes(TypeGenericProperties::class)] +class TypeGenericPropertiesNormalizer implements Normalizer +{ + private readonly Factory $factory; + + public function __construct( + private readonly Transformations $tt, + LocalDIC $container + ) { + $this->factory = $container[Factory::class]; + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof TypeGenericProperties) { + return [ + 'answer_form_id' => $this->tt->normalize($value->getAnswerFormId()), + 'question_id' => $this->tt->normalize($value->getQuestionId()), + 'definition' => $this->normalizeDefinition($value->getDefinition()), + 'available_points' => $value->getAvailablePoints(), + 'image_size' => $value->getImageSize(), + 'shuffle_answer_options' => $value->getShuffleAnswerOptions(), + 'additional_text' => $value->getAdditionalText(), + 'additional_text_legacy' => $value->getAdditionalTextLegacy(), + ]; + } + + throw new NormalizingException('Invalid definition value', $value); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): TypeGenericProperties + { + if (!is_array($value)) { + throw new NormalizingException('Invalid type generic properties value', $value); + } + + return new TypeGenericProperties( + $this->tt->denormalize($value['answer_form_id'], Uuid::class), + $this->tt->denormalize($value['question_id'], Uuid::class), + $this->denormalizeDefinition($value['definition']), + $this->tt->nullableFloat($value['available_points']), + $this->tt->nullableInt($value['image_size']), + $this->tt->nullableBool($value['shuffle_answer_options']), + $this->tt->string($value['additional_text']), + $this->tt->string($value['additional_text_legacy']), + ); + } + + private function normalizeDefinition(Definition $definition): ?string + { + return $definition !== null ? $definition::class : null; + } + + private function denormalizeDefinition(string $definition): ?Definition + { + return $definition !== null ? $this->factory->getDefinitionForClass($definition) : null; + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/UUIDNormalizer.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/UUIDNormalizer.php new file mode 100644 index 000000000000..acd1ae52d0b9 --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Normalizer/UUIDNormalizer.php @@ -0,0 +1,61 @@ + + */ +#[Normalizes(Uuid::class)] +class UUIDNormalizer implements Normalizer +{ + private readonly Factory $factory; + + public function __construct() + { + $this->factory = new Factory(); + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof Uuid) { + return $value->toString(); + } + + throw new NormalizingException('Invalid UUID value', $value); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): Uuid + { + return $this->factory->fromString((string) $value); + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/NormalizingException.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/NormalizingException.php new file mode 100644 index 000000000000..a2699dd40191 --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/NormalizingException.php @@ -0,0 +1,37 @@ +result = $result; + return $this; + } + + /** + * Get the result of the denormalization carry. If no result is set, an exception will be thrown. + * + * @throws NormalizingException if the result is not set + */ + public function result(): mixed + { + if (!isset($this->result)) { + $expected_type = get_debug_type($this->expected); + $normalized_type = get_debug_type($this->normalized); + throw new NormalizingException("Unsupported value, expected: {$expected_type}, got: {$normalized_type}"); + } + return $this->result; + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizingPipe.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizingPipe.php new file mode 100644 index 000000000000..0067cc6f5ce7 --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizingPipe.php @@ -0,0 +1,62 @@ + + */ +class DenormalizingPipe implements Pipe +{ + public function __construct( + private readonly Registry $registry + ) { + } + + public function handle(mixed $passable, \Closure $next): mixed + { + if (!$passable instanceof DenormalizeCarry) { + return $next($passable); + } + + // Check if normalizer for the expected type is registered. + if (is_string($passable->expected) && $normalizer = $this->registry->getNormalizerFor($passable->expected)) { + return $next( + $passable->setResult($normalizer->denormalize($passable->normalized, $passable->expected)) + ); + } + + // Use the fromNormalized method of the expected object to set the state of the object from the normalized form. + if ($passable->expected instanceof Normalizable) { + return $next( + $passable->setResult($passable->expected->fromNormalized($passable->transformations)->transform($passable->normalized)) + ); + } + + return $next($passable); + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Pipes/NormalizeCarry.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Pipes/NormalizeCarry.php new file mode 100644 index 000000000000..cc3f51ab64a0 --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Pipes/NormalizeCarry.php @@ -0,0 +1,44 @@ +result = $result; + return $this; + } + + /** + * Get the result of the normalization carry. If no result is set, an exception will be thrown. + * + * @throws NormalizingException if the result is not set + */ + public function result(): array|float|bool|int|string|null + { + if ($this->result === null) { + throw new NormalizingException('Unsupported value', $this->value); + } + return $this->result; + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Pipes/NormalizingPipe.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Pipes/NormalizingPipe.php new file mode 100644 index 000000000000..a1dd4f068d1b --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Pipes/NormalizingPipe.php @@ -0,0 +1,66 @@ + + */ +class NormalizingPipe implements Pipe +{ + public function __construct( + private readonly Registry $registry + ) { + } + + public function handle(mixed $passable, \Closure $next): mixed + { + if (!$passable instanceof NormalizeCarry) { + return $next($passable); + } + + if (is_scalar($passable->value)) { + return $next($passable->setResult($passable->value)); + } + + // Check if object is self-normalizable and use the toNormalized method + if ($passable->value instanceof Normalizable) { + $normalized = $passable->value->toNormalized($passable->transformations)->transform([]); + + return $next($passable->setResult($normalized)); + } + + // Lookup normalizer for the object type and use the normalize method + if ($normalizer = $this->registry->getNormalizerFor(get_class($passable->value))) { + return $next( + $passable->setResult($normalizer->normalize($passable->value)) + ); + } + + return $next($passable); + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Pipes/UUIDMappingPipe.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Pipes/UUIDMappingPipe.php new file mode 100644 index 000000000000..5be5461cc619 --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Pipes/UUIDMappingPipe.php @@ -0,0 +1,59 @@ + + */ +class UUIDMappingPipe implements Pipe +{ + private readonly Factory $uuid_factory; + + /** + * @var array + */ + private array $mappings = []; + + public function __construct() + { + $this->uuid_factory = new Factory(); + } + + public function handle(mixed $passable, \Closure $next): mixed + { + if (!$passable instanceof Uuid) { + return $next($passable); + } + + $uuid = $passable->toString(); + if (!array_key_exists($uuid, $this->mappings)) { + $this->mappings[$uuid] = $this->uuid_factory->uuid4(); + } + + return $this->mappings[$uuid]; + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Transformations.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Transformations.php new file mode 100644 index 000000000000..1d60e925af3e --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Normalizing/Transformations.php @@ -0,0 +1,152 @@ +normalize(...), $value); + } + + return $this->pipeline->send(new NormalizeCarry($this, $value)) + ->then(fn(NormalizeCarry $carry) => $carry->result()); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $normalized, string|object $expected): mixed + { + if ($normalized === null) { + return null; + } + + return $this->pipeline->send(new DenormalizeCarry($this, $normalized, $expected)) + ->then(fn(DenormalizeCarry $carry) => $carry->result()); + } + + /** + * Get a pipe from the pipeline. + * + * @template T of Pipe + * @param class-string $pipe + * @return T + * + * @throws NormalizingException if the pipe is not found + */ + public function context(string $pipe): Pipe + { + foreach ($this->pipeline->pipes() as $pipe) { + if ($pipe instanceof $pipe) { + return $pipe; + } + } + throw new NormalizingException("Pipe {$pipe} not found"); + } + + /* + Transformations + */ + + public function custom(): Group + { + return $this->refinery->custom(); + } + + /** + * @throws InvalidArgumentException if the value cannot be transformed into an integer + */ + public function int(mixed $value): int + { + return $this->refinery->kindlyTo()->int()->transform($value); + } + + /** + * @throws InvalidArgumentException if the value cannot be transformed into a float + */ + public function float(mixed $value): float + { + return $this->refinery->kindlyTo()->float()->transform($value); + } + + /** + * @throws InvalidArgumentException if the value cannot be transformed into a string + */ + public function string(mixed $value): string + { + return $this->refinery->kindlyTo()->string()->transform($value); + } + + /** + * @throws InvalidArgumentException if the value cannot be transformed into a boolean + */ + public function bool(mixed $value): bool + { + return $this->refinery->kindlyTo()->bool()->transform($value); + } + + public function nullableInt(mixed $value): ?int + { + return $this->refinery->byTrying([ + $this->refinery->kindlyTo()->int(), + $this->refinery->always(null) + ])->transform($value); + } + + public function nullableFloat(mixed $value): ?float + { + return $this->refinery->byTrying([ + $this->refinery->kindlyTo()->float(), + $this->refinery->always(null) + ])->transform($value); + } + + public function nullableString(mixed $value): ?string + { + return $this->refinery->byTrying([ + $this->refinery->kindlyTo()->string(), + $this->refinery->always(null) + ])->transform($value); + } + + public function nullableBool(mixed $value): ?bool + { + return $this->refinery->byTrying([ + $this->refinery->kindlyTo()->bool(), + $this->refinery->always(null) + ])->transform($value); + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Objects/IdContainer.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Objects/IdContainer.php new file mode 100644 index 000000000000..53a0fa630051 --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Objects/IdContainer.php @@ -0,0 +1,52 @@ +id; + } + + public function getObject(): string + { + return $this->object; + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Pipeline.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Pipeline.php new file mode 100644 index 000000000000..ff6aa20eaead --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Pipeline.php @@ -0,0 +1,200 @@ + + * + * @phpstan-type PipeParam \Closure(TPassable, PipeParam)|Pipe: TPassable + */ +class Pipeline implements PipelineContract +{ + /** + * The object being passed through the pipeline. + * + * @var TPassable $passable + */ + protected mixed $passable; + + /** + * The array of class pipes. + * + * @var list $pipes + */ + protected array $pipes = []; + + /** + * The final callback to be executed after the pipeline ends regardless of the outcome. + */ + protected ?Closure $finally = null; + + + /** + * @inheritDoc + */ + public function send(mixed $passable): self + { + $this->passable = $passable; + return $this; + } + + /** + * @inheritDoc + */ + public function through(array $pipes): self + { + $this->pipes = $pipes; + return $this; + } + + /** + * @inheritDoc + */ + public function pipe(Closure|Pipe $pipe): self + { + $this->pipes[] = $pipe; + return $this; + } + + /** + * @inheritDoc + */ + public function pipeWhen(Closure $condition, Closure|Pipe $pipe): self + { + return $this->pipe(fn($passable, $next) => $condition($passable) + ? $this->executePipe($pipe, $passable, $next) + : $next($passable)); + } + + /** + * @inheritDoc + */ + public function pipeUnless(Closure $condition, Closure|Pipe $pipe): self + { + return $this->pipeWhen(fn($passable) => !$condition($passable), $pipe); + } + + /** + * @inheritDoc + */ + public function then(Closure $destination): mixed + { + $pipeline = array_reduce( + array_reverse($this->pipes), + $this->carry(), + $this->prepareDestination($destination) + ); + + try { + return $pipeline($this->passable); + } finally { + if ($this->finally) { + ($this->finally)($this->passable); + } + } + } + + /** + * @inheritDoc + */ + public function thenReturn(): mixed + { + return $this->then(fn($passable) => $passable); + } + + /** + * @inheritDoc + */ + public function finally(Closure $callback): self + { + $this->finally = $callback; + return $this; + } + + /** + * @inheritDoc + */ + public function pipes(): array + { + return $this->pipes; + } + + /** + * Get the final piece of the Closure onion. + */ + protected function prepareDestination(Closure $destination) + { + return function ($passable) use ($destination) { + try { + return $destination($passable); + } catch (Throwable $e) { + return $this->handleException($passable, $e); + } + }; + } + + /** + * Get a Closure that represents a slice of the application onion. + */ + protected function carry() + { + return fn($stack, $pipe) => function ($passable) use ($stack, $pipe) { + try { + return $this->executePipe($pipe, $passable, $stack); + } catch (Throwable $e) { + return $this->handleException($passable, $e); + } + }; + } + + /** + * Execute a single pipe, handling both Closure and Pipe instances. + * Pipe instances are skipped when their skip() method returns true. + */ + protected function executePipe(Closure|Pipe $pipe, mixed $passable, Closure $next): mixed + { + if ($pipe instanceof Pipe) { + return $pipe->handle($passable, $next); + } + + if (is_callable($pipe)) { + return $pipe($passable, $next); + } + + throw new \InvalidArgumentException('Invalid pipe'); + } + + /** + * Handle the given exception. + * + * @throws \Throwable + */ + protected function handleException(mixed $passable, Throwable $e): void + { + throw $e; + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Serializing/JSONMemoryDeserializer.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Serializing/JSONMemoryDeserializer.php new file mode 100644 index 000000000000..00624223c06b --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Serializing/JSONMemoryDeserializer.php @@ -0,0 +1,70 @@ + $handler */ + private array $handler = []; + + /** + * @inheritDoc + */ + public function open(string $json): static + { + $clone = clone $this; + $clone->decoded = json_decode($json, true); + return $clone; + } + + /** + * @inheritDoc + */ + public function addHandler(string $group, callable $handler): void + { + $this->handler[$group] = $handler; + } + + /** + * @inheritDoc + */ + public function process(): void + { + foreach ($this->decoded as $key => $value) { + if (is_array($value)) { + $head = $value[array_key_first($value)]; + $value = (!array_is_list($head)) ? [$head] : $head; + } + + if (isset($this->handler[$key])) { + $this->handler[$key]($value); + } + } + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Serializing/JSONMemorySerializer.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Serializing/JSONMemorySerializer.php new file mode 100644 index 000000000000..9ea23bdc6856 --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Serializing/JSONMemorySerializer.php @@ -0,0 +1,113 @@ + Stack of for nested structure + */ + private array $stack = []; + + /** + * @inheritDoc + */ + public function open(string $path): static + { + $clone = clone $this; + $clone->stack = [ + ['name' => '', 'data' => []], + ]; + return $clone; + } + + /** + * @inheritDoc + */ + public function startGroup(string $name): void + { + $this->stack[] = ['name' => $name, 'data' => []]; + } + + /** + * @inheritDoc + */ + public function endGroup(string $name): void + { + $frame = array_pop($this->stack); + if ($frame['name'] !== $name) { + throw new \LogicException( + "Group name mismatch: expected end of '{$frame['name']}', got '{$name}'" + ); + } + $top = &$this->stack[array_key_last($this->stack)]; + $top['data'][$name] = $frame['data']; + } + + /** + * @inheritDoc + */ + public function group(string $name, callable $callback): void + { + $this->startGroup($name); + $callback(); + $this->endGroup($name); + } + + /** + * @inheritDoc + */ + public function append(string $name, array $data): void + { + $top = &$this->stack[array_key_last($this->stack)]; + if (array_key_exists($name, $top['data'])) { + $existing = $top['data'][$name]; + if (is_array($existing) && array_is_list($existing)) { + $top['data'][$name][] = $data; + } else { + $top['data'][$name] = [$existing, $data]; + } + } else { + $top['data'][$name] = $data; + } + } + + /** + * @inheritDoc + */ + public function write(): string + { + if ($this->stack === []) { + return '{}'; + } + $root = $this->stack[0]['data']; + $json = json_encode($root, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + $this->stack = [ + ['name' => '', 'data' => []], + ]; + return $json; + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php b/components/ILIAS/Questions/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php new file mode 100644 index 000000000000..0d2fbe2c4f6f --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php @@ -0,0 +1,149 @@ +writer = new \XMLWriter(); + } + + /** + * @inheritDoc + */ + public function open(string $path): static + { + $clone = clone $this; + $clone->writer->openMemory(); + $clone->writer->setIndent(true); + $clone->writer->startDocument('1.0', 'UTF-8'); + $clone->writer->writeComment("Example Export"); + $clone->writer->startElement('export'); + return $clone; + } + + /** + * @inheritDoc + */ + public function startGroup(string $name): void + { + $this->current_group = $name; + $this->writer->startElement($this->formatName($name)); + } + + /** + * @inheritDoc + */ + public function endGroup(string $name): void + { + if ($this->current_group !== $name) { + throw new \LogicException( + "Group name mismatch: expected end of '{$this->current_group}', got '{$name}'" + ); + } + + $this->current_group = ''; + $this->writer->endElement(); + } + + /** + * @inheritDoc + */ + public function group(string $name, callable $callback): void + { + $this->startGroup($name); + $callback(); + $this->endGroup($name); + } + + /** + * @inheritDoc + */ + public function append(string $name, array $data): void + { + $this->writer->startElement($this->formatName($name)); + $this->appendRecursive($data); + $this->writer->endElement(); + } + + /** + * @inheritDoc + */ + public function write(): string + { + $this->writer->endElement(); + $this->writer->endDocument(); + return $this->writer->outputMemory(true); + } + + /** + * @param array $data + */ + private function appendRecursive(array $data): void + { + foreach ($data as $key => $value) { + $type = gettype($value); + $is_nested = is_array($value); + + if (is_numeric($key) || str_contains($key, '-') || $key === '') { + $this->writer->startElement('item'); + $this->writer->writeAttribute('key', (string) $key); + } else { + $this->writer->startElement($this->formatName($key)); + } + + if (!$is_nested) { + if ($type !== 'string') { + $this->writer->writeAttribute('type', $type); + } + + $value = match ($type) { + 'NULL' => 'NULL', + 'boolean' => $value ? 'true' : 'false', + default => htmlspecialchars((string) $value), + }; + + $this->writer->writeRaw($value); + } else { + $this->appendRecursive($value); + } + + $this->writer->endElement(); + } + } + + private function formatName(int|string $name): string + { + // Transform key to kebab-case + $output = strtolower(preg_replace('/(?repository->getQuestionDataOnlyForAllQuestions() as $question_data) { + $question = $this->repository->getForQuestionId($question_data->getId()); + $this->answer_forms = [...$this->answer_forms, ...$question->getAnswerForms()]; + yield $question; + } + } + + public function getAnswerForms(): array + { + return $this->answer_forms; + } + + public function withExampleConfig(): self + { + $clone = clone $this; + $clone->example_config = true; + return $clone; + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/QuestionsExporter.php b/components/ILIAS/Questions/src/ExportImport/QuestionsExporter.php new file mode 100644 index 000000000000..5639c4da0128 --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/QuestionsExporter.php @@ -0,0 +1,49 @@ +builder->create(); + + $serializer->group('questions', function () use ($transformations, $collector, $serializer): void { + foreach ($collector->getQuestions() as $question) { + $serializer->append( + 'question', + $transformations->normalize($question), + ); + } + }); + + return $serializer->write(); + + } +} diff --git a/components/ILIAS/Questions/src/ExportImport/QuestionsImporter.php b/components/ILIAS/Questions/src/ExportImport/QuestionsImporter.php new file mode 100644 index 000000000000..ec2ccc917cbe --- /dev/null +++ b/components/ILIAS/Questions/src/ExportImport/QuestionsImporter.php @@ -0,0 +1,69 @@ +builder->withEnableMappings()->withLegacyNormalizers('11')->create(); + $transformations = $this->builder->withEnableMappings()->create(); + + $deserializer->addHandler( + 'questions', + fn($data) => $this->importQuestions($data, $parent_obj_id, $transformations) + ); + + $deserializer->process(); + } + + private function importQuestions(array $data, int $parent_obj_id, Transformations $tt): void + { + foreach ($data as $payload) { + $this->importQuestion($payload, $parent_obj_id, $tt); + } + } + + private function importQuestion(array $payload, int $parent_obj_id, Transformations $tt) + { + //TODO: $tt->storeMapping($normalized['id'], $clone->id); + $question = $tt->denormalize( + $payload, + $this->repository->getNew($parent_obj_id) + ); + dd($payload, $question); + //$this->repository->create($questions); + // Siehe \ilPCAnswerFormGUI::insertCmd + // Siehe \ilPCAnswerForm::create + return $question; + } +} diff --git a/components/ILIAS/Questions/src/Question/Question.php b/components/ILIAS/Questions/src/Question/Question.php index 8ffa5808e0ca..b445960e324d 100644 --- a/components/ILIAS/Questions/src/Question/Question.php +++ b/components/ILIAS/Questions/src/Question/Question.php @@ -21,8 +21,9 @@ namespace ILIAS\Questions\Question; use ILIAS\Questions\Question\Views\Participant; +use ILIAS\Questions\ExportImport\Foundation\Contracts\Normalizable; -interface Question +interface Question extends Normalizable { public function getParticipantView(): Participant; } diff --git a/components/ILIAS/Questions/src/Question/QuestionImplementation.php b/components/ILIAS/Questions/src/Question/QuestionImplementation.php index dc53fde12789..357c5cca65c5 100644 --- a/components/ILIAS/Questions/src/Question/QuestionImplementation.php +++ b/components/ILIAS/Questions/src/Question/QuestionImplementation.php @@ -21,6 +21,8 @@ namespace ILIAS\Questions\Question; use ILIAS\Questions\AnswerForm\Properties as AnswerFormProperties; +use ILIAS\Questions\AnswerForm\TypeGenericProperties; +use ILIAS\Questions\ExportImport\Foundation\Contracts\Transformations; use ILIAS\Questions\Persistence\CoreTables; use ILIAS\Questions\Persistence\Column; use ILIAS\Questions\Persistence\Delete; @@ -34,6 +36,7 @@ use ILIAS\Questions\Question\Definitions\Lifecycle; use ILIAS\Data\UUID\Uuid; use ILIAS\Language\Language; +use ILIAS\Refinery\Transformation; use ILIAS\UI\Factory as UIFactory; use ILIAS\UI\Component\Link\Factory as LinkFactory; use ILIAS\UI\Component\Link\Standard as StandardLink; @@ -581,4 +584,48 @@ private function buildUpdatePageIdStatement(): Update ] ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'id' => $tt->normalize($this->id), + 'parent_obj_id' => $this->parent_obj_id, + 'position' => $this->position, + 'page_id' => $this->page_id, + 'title' => $this->title, + 'author' => $this->author, + 'lifecycle' => $this->lifecycle->value, + 'remarks' => $this->remarks, + 'original_id' => $tt->normalize($this->original_id), + 'last_update' => $tt->normalize($this->last_update), + 'created' => $tt->normalize($this->created), + 'answer_forms' => $tt->normalize($this->answer_forms), + 'taxonomies' => $tt->normalize($this->taxonomies), + 'content_for_recapitulation' => $tt->normalize($this->content_for_recapitulation), + ]); + } + + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): QuestionImplementation { + $clone = clone $this; + $clone->position = $tt->nullableInt($normalized['position']); + $clone->title = $tt->string($normalized['title']); + $clone->author = $tt->string($normalized['author']); + $clone->lifecycle = Lifecycle::from($tt->string($normalized['lifecycle'])); + $clone->remarks = $tt->string($normalized['remarks']); + $clone->original_id = $tt->denormalize($normalized['id'], Uuid::class); + + foreach ($normalized['answer_forms'] as $data) { + $generic_properties = $tt->denormalize($data, TypeGenericProperties::class); + $properties = $generic_properties->getDefinition()->buildProperties($generic_properties, null); + $clone = $clone->withAnswerForm($tt->denormalize($data, $properties)); + } + return $clone; + }); + //TODO: taxonomies, content for recapitulation + } } diff --git a/components/ILIAS/Questions/src/Setup/Agent.php b/components/ILIAS/Questions/src/Setup/Agent.php index a8708db82f2e..4fe9a2401750 100644 --- a/components/ILIAS/Questions/src/Setup/Agent.php +++ b/components/ILIAS/Questions/src/Setup/Agent.php @@ -22,6 +22,7 @@ use ILIAS\Questions\Persistence\TableNameBuilder; use ILIAS\Questions\Persistence\TableNameSpaceCore; +use ILIAS\Questions\Setup\Artifact\NormalizerArtifactObjective; use ILIAS\Refinery\Transformation; use ILIAS\Setup\Agent as SetupAgent; use ILIAS\Setup\Agent\HasNoNamedObjective; @@ -109,7 +110,7 @@ public function getMigrations(): array #[\Override] public function getBuildObjective(): Objective { - return new NullObjective(); + return new NormalizerArtifactObjective(); } #[\Override] diff --git a/components/ILIAS/Questions/src/Setup/Artifact/NormalizerArtifactObjective.php b/components/ILIAS/Questions/src/Setup/Artifact/NormalizerArtifactObjective.php new file mode 100644 index 000000000000..9f8883d16d45 --- /dev/null +++ b/components/ILIAS/Questions/src/Setup/Artifact/NormalizerArtifactObjective.php @@ -0,0 +1,80 @@ +getMatchingClassNames(Normalizer::class) as $class_name) { + $ref = new ReflectionClass($class_name); + + $attrs = $ref->getAttributes(Normalizes::class); + foreach ($attrs as $attr) { + $instance = $attr->newInstance(); + foreach ($instance->types as $type) { + $type_map[$type][self::DEFAULT_KEY] = $class_name; + } + } + + $attrs = $ref->getAttributes(NormalizesLegacy::class); + foreach ($attrs as $attr) { + $instance = $attr->newInstance(); + foreach ($instance->types as $type) { + $type_map[$type][$instance->version] = $class_name; + } + } + } + + return new ArrayArtifact($type_map); + } +}