diff --git a/composer.json b/composer.json
index 95be44089..43451fe14 100644
--- a/composer.json
+++ b/composer.json
@@ -28,7 +28,8 @@
"drupal/token" : "^1.3",
"drupal/flysystem" : "^2.0@beta",
"islandora/crayfish-commons": "^2",
- "drupal/file_replace": "^1.1"
+ "drupal/file_replace": "^1.1",
+ "drupal/ctools": "^3.8 || ^4"
},
"require-dev": {
"phpunit/phpunit": "^6",
@@ -37,7 +38,7 @@
"sebastian/phpcpd": "*"
},
"suggest": {
- "drupal/transliterate_filenames": "Sanitizes filenames when they are uploaded so they don't break your repository."
+ "drupal/transliterate_filenames": "Sanitizes filenames when they are uploaded so they don't break your repository."
},
"license": "GPL-2.0-or-later",
"authors": [
diff --git a/islandora.info.yml b/islandora.info.yml
index 1228e80e9..a1f2b1f43 100644
--- a/islandora.info.yml
+++ b/islandora.info.yml
@@ -12,22 +12,23 @@ dependencies:
- drupal:text
- drupal:options
- drupal:link
- - drupal:jsonld
- - drupal:search_api
- - drupal:jwt
+ - jsonld:jsonld
+ - search_api:search_api
+ - jwt:jwt
- drupal:rest
- - drupal:filehash
+ - filehash:filehash
- drupal:basic_auth
- - drupal:context_ui
+ - context:context_ui
- drupal:action
- - drupal:eva
+ - eva:eva
- drupal:taxonomy
- drupal:views_ui
- drupal:media
- - drupal:prepopulate
- - drupal:features_ui
- - drupal:migrate_source_csv
+ - prepopulate:prepopulate
+ - features:features_ui
+ - migrate_source_csv:migrate_source_csv
- drupal:content_translation
- - drupal:flysystem
- - drupal:token
- - drupal:file_replace
+ - flysystem:flysystem
+ - token:token
+ - file_replace:file_replace
+ - ctools:ctools
diff --git a/islandora.install b/islandora.install
index f9eb1225f..ad2eb8e1c 100644
--- a/islandora.install
+++ b/islandora.install
@@ -5,6 +5,10 @@
* Install/update hook implementations.
*/
+use Drupal\Core\Extension\ExtensionNameLengthException;
+use Drupal\Core\Extension\MissingDependencyException;
+use Drupal\Core\Utility\UpdateException;
+
/**
* Adds common namespaces to jsonld.settings.
*/
@@ -174,3 +178,37 @@ function update_jsonld_included_namespaces() {
->warning("Could not find required jsonld.settings to add default RDF namespaces.");
}
}
+
+/**
+ * Ensure that ctools is enabled.
+ */
+function islandora_update_8007() {
+ $module_handler = \Drupal::moduleHandler();
+ if ($module_handler->moduleExists('ctools')) {
+ return t('The "@module_name" module is already enabled, no action necessary.', [
+ '@module_name' => 'ctools',
+ ]);
+ }
+
+ /** @var \Drupal\Core\Extension\ModuleInstallerInterface $installer */
+ $installer = \Drupal::service('module_installer');
+
+ try {
+ if ($installer->install(['ctools'], TRUE)) {
+ return t('The "@module_name" module was enabled successfully.', [
+ '@module_name' => 'ctools',
+ ]);
+ }
+ }
+ catch (ExtensionNameLengthException | MissingDependencyException $e) {
+ throw new UpdateException('Failed; ensure that the ctools module is available in the Drupal installation.', 0, $e);
+ }
+ catch (\Exception $e) {
+ throw new UpdateException('Failed; encountered an exception while trying to enable ctools.', 0, $e);
+ }
+
+ // Theoretically impossible to hit, as ModuleInstaller::install() only returns
+ // TRUE (or throws/propagates an exception), but... probably a good idea to
+ // have the here, just in case?
+ throw new UpdateException('Failed; hit the end of the update hook implementation, which is not expected.');
+}
diff --git a/islandora.routing.yml b/islandora.routing.yml
index 5387e9a47..86d134828 100644
--- a/islandora.routing.yml
+++ b/islandora.routing.yml
@@ -37,14 +37,15 @@ islandora.add_member_to_node_page:
_entity_create_any_access: 'node'
islandora.upload_children:
- path: '/node/{node}/members/upload'
+ path: '/node/{node}/members/upload/{step}'
defaults:
- _form: '\Drupal\islandora\Form\AddChildrenForm'
+ _wizard: '\Drupal\islandora\Form\AddChildrenWizard\ChildForm'
_title: 'Upload Children'
+ step: 'type_selection'
options:
_admin_route: 'TRUE'
requirements:
- _custom_access: '\Drupal\islandora\Form\AddChildrenForm::access'
+ _custom_access: '\Drupal\islandora\Form\AddChildrenWizard\Access::childAccess'
islandora.add_media_to_node_page:
path: '/node/{node}/media/add'
@@ -58,14 +59,15 @@ islandora.add_media_to_node_page:
_entity_create_any_access: 'media'
islandora.upload_media:
- path: '/node/{node}/media/upload'
+ path: '/node/{node}/media/upload/{step}'
defaults:
- _form: '\Drupal\islandora\Form\AddMediaForm'
+ _wizard: '\Drupal\islandora\Form\AddChildrenWizard\MediaForm'
_title: 'Add media'
+ step: 'type_selection'
options:
_admin_route: 'TRUE'
requirements:
- _custom_access: '\Drupal\islandora\Form\AddMediaForm::access'
+ _custom_access: '\Drupal\islandora\Form\AddChildrenWizard\Access::mediaAccess'
islandora.media_source_update:
path: '/media/{media}/source'
diff --git a/islandora.services.yml b/islandora.services.yml
index 4b3a9d16e..4108e2446 100644
--- a/islandora.services.yml
+++ b/islandora.services.yml
@@ -59,3 +59,19 @@ services:
arguments: ['@jwt.authentication.jwt']
tags:
- { name: event_subscriber }
+ islandora.upload_children.batch_processor:
+ class: Drupal\islandora\Form\AddChildrenWizard\ChildBatchProcessor
+ arguments:
+ - '@entity_type.manager'
+ - '@database'
+ - '@current_user'
+ - '@messenger'
+ - '@date.formatter'
+ islandora.upload_media.batch_processor:
+ class: Drupal\islandora\Form\AddChildrenWizard\MediaBatchProcessor
+ arguments:
+ - '@entity_type.manager'
+ - '@database'
+ - '@current_user'
+ - '@messenger'
+ - '@date.formatter'
diff --git a/src/Form/AddChildrenForm.php b/src/Form/AddChildrenForm.php
index 0ff724962..528b42832 100644
--- a/src/Form/AddChildrenForm.php
+++ b/src/Form/AddChildrenForm.php
@@ -229,7 +229,7 @@ public function buildNodeFinished($success, $results, $operations) {
* @param \Drupal\Core\Routing\RouteMatch $route_match
* The current routing match.
*
- * @return \Drupal\Core\Access\AccessResultAllowed|\Drupal\Core\Access\AccessResultForbidden
+ * @return \Drupal\Core\Access\AccessResultInterface
* Whether we can or can't show the "thing".
*/
public function access(RouteMatch $route_match) {
diff --git a/src/Form/AddChildrenWizard/AbstractBatchProcessor.php b/src/Form/AddChildrenWizard/AbstractBatchProcessor.php
new file mode 100644
index 000000000..6193c0c30
--- /dev/null
+++ b/src/Form/AddChildrenWizard/AbstractBatchProcessor.php
@@ -0,0 +1,258 @@
+entityTypeManager = $entity_type_manager;
+ $this->database = $database;
+ $this->currentUser = $current_user;
+ $this->messenger = $messenger;
+ $this->dateFormatter = $date_formatter;
+ }
+
+ /**
+ * Implements callback_batch_operation() for our child addition batch.
+ */
+ public function batchOperation($delta, $info, array $values, &$context) {
+ $transaction = $this->database->startTransaction();
+
+ try {
+ $entities[] = $node = $this->getNode($info, $values);
+ $entities[] = $this->createMedia($node, $info, $values);
+
+ $context['results'] = array_merge_recursive($context['results'], [
+ 'validation_violations' => $this->validationClassification($entities),
+ ]);
+ $context['results']['count'] = ($context['results']['count'] ?? 0) + 1;
+ }
+ catch (HttpExceptionInterface $e) {
+ $transaction->rollBack();
+ throw $e;
+ }
+ catch (\Exception $e) {
+ $transaction->rollBack();
+ throw new HttpException(500, $e->getMessage(), $e);
+ }
+ }
+
+ /**
+ * Loads the file indicated.
+ *
+ * @param mixed $info
+ * Widget values.
+ *
+ * @return \Drupal\file\FileInterface|null
+ * The loaded file.
+ *
+ * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+ * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+ */
+ protected function getFile($info) : ?FileInterface {
+ return (is_array($info) && isset($info['target_id'])) ?
+ $this->entityTypeManager->getStorage('file')->load($info['target_id']) :
+ NULL;
+ }
+
+ /**
+ * Get the node to which to attach our media.
+ *
+ * @param mixed $info
+ * Info from the widget used to create the request.
+ * @param array $values
+ * Additional form inputs.
+ *
+ * @return \Drupal\node\NodeInterface
+ * The node to which to attach the created media.
+ */
+ abstract protected function getNode($info, array $values) : NodeInterface;
+
+ /**
+ * Get a name to use for bulk-created assets.
+ *
+ * @param mixed $info
+ * Widget values.
+ * @param array $values
+ * Form values.
+ *
+ * @return string
+ * An applicable name.
+ *
+ * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+ * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+ */
+ protected function getName($info, array $values) : string {
+ $file = $this->getFile($info);
+ return $file ? $file->getFilename() : strtr('Bulk ingest, {date}', [
+ '{date}' => $this->dateFormatter->format(time(), 'long'),
+ ]);
+ }
+
+ /**
+ * Create a media referencing the given file, associated with the given node.
+ *
+ * @param \Drupal\node\NodeInterface $node
+ * The node to which the media should be associated.
+ * @param mixed $info
+ * The widget info for the media source field.
+ * @param array $values
+ * Values from the wizard, which should contain at least:
+ * - media_type: The machine name/ID of the media type as which to create
+ * the media
+ * - use: An array of the selected "media use" terms.
+ *
+ * @return \Drupal\media\MediaInterface
+ * The created media entity.
+ *
+ * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+ * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+ * @throws \Drupal\Core\Entity\EntityStorageException
+ */
+ protected function createMedia(NodeInterface $node, $info, array $values) : MediaInterface {
+ $taxonomy_term_storage = $this->entityTypeManager->getStorage('taxonomy_term');
+
+ // Create a media with the file attached and also pointing at the node.
+ $field = $this->getField($values);
+
+ $media_values = array_merge(
+ [
+ 'bundle' => $values['media_type'],
+ 'name' => $this->getName($info, $values),
+ IslandoraUtils::MEDIA_OF_FIELD => $node,
+ IslandoraUtils::MEDIA_USAGE_FIELD => ($values['use'] ?
+ $taxonomy_term_storage->loadMultiple($values['use']) :
+ NULL),
+ 'uid' => $this->currentUser->id(),
+ // XXX: Published... no constant?
+ 'status' => 1,
+ ],
+ [
+ $field->getName() => [
+ $info,
+ ],
+ ]
+ );
+ $media = $this->entityTypeManager->getStorage('media')->create($media_values);
+ if ($media->save() !== SAVED_NEW) {
+ throw new \Exception("Failed to create media.");
+ }
+
+ return $media;
+ }
+
+ /**
+ * Helper to bulk process validatable entities.
+ *
+ * @param array $entities
+ * An array of entities to scan for validation violations.
+ *
+ * @return array
+ * An associative array mapping entity type IDs to entity IDs to a count
+ * of validation violations found on then given entity.
+ */
+ protected function validationClassification(array $entities) {
+ $violations = [];
+
+ foreach ($entities as $entity) {
+ $entity_violations = $entity->validate();
+ if ($entity_violations->count() > 0) {
+ $violations[$entity->getEntityTypeId()][$entity->id()] = $entity_violations->count();
+ }
+ }
+
+ return $violations;
+ }
+
+ /**
+ * Implements callback_batch_finished() for our child addition batch.
+ */
+ public function batchProcessFinished($success, $results, $operations): void {
+ if ($success) {
+ foreach ($results['validation_violations'] ?? [] as $entity_type => $info) {
+ foreach ($info as $id => $count) {
+ $this->messenger->addWarning($this->formatPlural(
+ $count,
+ '1 validation error present in bulk created entity of type %type, with ID %id.',
+ '@count validation errors present in bulk created entity of type %type, with ID %id.',
+ [
+ '%type' => $entity_type,
+ ':uri' => Url::fromRoute("entity.{$entity_type}.canonical", [$entity_type => $id])->toString(),
+ '%id' => $id,
+ ]
+ ));
+ }
+ }
+ }
+ else {
+ $this->messenger->addError($this->t('Encountered an error when processing.'));
+ }
+ }
+
+}
diff --git a/src/Form/AddChildrenWizard/AbstractFileSelectionForm.php b/src/Form/AddChildrenWizard/AbstractFileSelectionForm.php
new file mode 100644
index 000000000..6aeed8795
--- /dev/null
+++ b/src/Form/AddChildrenWizard/AbstractFileSelectionForm.php
@@ -0,0 +1,157 @@
+entityTypeManager = $container->get('entity_type.manager');
+ $instance->widgetPluginManager = $container->get('plugin.manager.field.widget');
+ $instance->entityFieldManager = $container->get('entity_field.manager');
+ $instance->currentUser = $container->get('current_user');
+
+ $instance->batchProcessor = $container->get(static::BATCH_PROCESSOR);
+
+ return $instance;
+ }
+
+ /**
+ * Helper; get the media type, based off discovering from form state.
+ *
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The form state.
+ *
+ * @return \Drupal\media\MediaTypeInterface
+ * The target media type.
+ *
+ * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+ * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+ */
+ protected function getMediaTypeFromFormState(FormStateInterface $form_state): MediaTypeInterface {
+ return $this->getMediaType($form_state->getTemporaryValue('wizard'));
+ }
+
+ /**
+ * Helper; get field instance, based off discovering from form state.
+ *
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The form state.
+ *
+ * @return \Drupal\Core\Field\FieldDefinitionInterface
+ * The field definition.
+ */
+ protected function getFieldFromFormState(FormStateInterface $form_state): FieldDefinitionInterface {
+ $cached_values = $form_state->getTemporaryValue('wizard');
+
+ $field = $this->getField($cached_values);
+ $def = $field->getFieldStorageDefinition();
+ if ($def instanceof FieldStorageConfigInterface) {
+ $def->set('cardinality', FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
+ }
+ elseif ($def instanceof BaseFieldDefinition) {
+ $def->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
+ }
+ else {
+ throw new \Exception('Unable to remove cardinality limit.');
+ }
+
+ return $field;
+ }
+
+ /**
+ * Helper; get widget for the field, based on discovering from form state.
+ *
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The form state.
+ *
+ * @return \Drupal\Core\Field\WidgetInterface
+ * The widget.
+ */
+ protected function getWidgetFromFormState(FormStateInterface $form_state): WidgetInterface {
+ return $this->getWidget($this->getFieldFromFormState($form_state));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state): array {
+ // Using the media type selected in the previous step, grab the
+ // media bundle's "source" field, and create a multi-file upload widget
+ // for it, with the same kind of constraints.
+ $field = $this->getFieldFromFormState($form_state);
+ $items = FieldItemList::createInstance($field, $field->getName(), $this->getMediaTypeFromFormState($form_state)->getTypedData());
+
+ $form['#tree'] = TRUE;
+ $form['#parents'] = [];
+ $widget = $this->getWidgetFromFormState($form_state);
+ $form['files'] = $widget->form(
+ $items,
+ $form,
+ $form_state
+ );
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $cached_values = $form_state->getTemporaryValue('wizard');
+
+ $widget = $this->getWidgetFromFormState($form_state);
+ $builder = (new BatchBuilder())
+ ->setTitle($this->t('Bulk creating...'))
+ ->setInitMessage($this->t('Initializing...'))
+ ->setFinishCallback([$this->batchProcessor, 'batchProcessFinished']);
+ $values = $form_state->getValue($this->getField($cached_values)->getName());
+ $massaged_values = $widget->massageFormValues($values, $form, $form_state);
+ foreach ($massaged_values as $delta => $info) {
+ $builder->addOperation(
+ [$this->batchProcessor, 'batchOperation'],
+ [$delta, $info, $cached_values]
+ );
+ }
+ batch_set($builder->toArray());
+ }
+
+}
diff --git a/src/Form/AddChildrenWizard/AbstractForm.php b/src/Form/AddChildrenWizard/AbstractForm.php
new file mode 100644
index 000000000..e9fac3875
--- /dev/null
+++ b/src/Form/AddChildrenWizard/AbstractForm.php
@@ -0,0 +1,125 @@
+nodeId = $this->routeMatch->getParameter('node');
+ $this->currentUser = $current_user;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getParameters() : array {
+ return array_merge(
+ parent::getParameters(),
+ [
+ 'tempstore_id' => static::TEMPSTORE_ID,
+ 'current_user' => \Drupal::service('current_user'),
+ ]
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getOperations($cached_values) {
+ $ops = [];
+
+ $ops['type_selection'] = [
+ 'title' => $this->t('Type Selection'),
+ 'form' => static::TYPE_SELECTION_FORM,
+ 'values' => [
+ 'node' => $this->nodeId,
+ ],
+ ];
+ $ops['file_selection'] = [
+ 'title' => $this->t('Widget Input for Selected Type'),
+ 'form' => static::FILE_SELECTION_FORM,
+ 'values' => [
+ 'node' => $this->nodeId,
+ ],
+ ];
+
+ return $ops;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getNextParameters($cached_values) {
+ return parent::getNextParameters($cached_values) + ['node' => $this->nodeId];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getPreviousParameters($cached_values) {
+ return parent::getPreviousParameters($cached_values) + ['node' => $this->nodeId];
+ }
+
+}
diff --git a/src/Form/AddChildrenWizard/Access.php b/src/Form/AddChildrenWizard/Access.php
new file mode 100644
index 000000000..0adafde51
--- /dev/null
+++ b/src/Form/AddChildrenWizard/Access.php
@@ -0,0 +1,71 @@
+utils = $utils;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) : self {
+ return new static(
+ $container->get('islandora.utils')
+ );
+ }
+
+ /**
+ * Check if the user can create any "Islandora" nodes and media.
+ *
+ * @param \Drupal\Core\Routing\RouteMatch $route_match
+ * The current routing match.
+ *
+ * @return \Drupal\Core\Access\AccessResultInterface
+ * Whether we can or cannot show the "thing".
+ */
+ public function childAccess(RouteMatch $route_match) : AccessResultInterface {
+ return AccessResult::allowedIf($this->utils->canCreateIslandoraEntity('node', 'node_type'))
+ ->andIf($this->mediaAccess($route_match));
+
+ }
+
+ /**
+ * Check if the user can create any "Islandora" media.
+ *
+ * @param \Drupal\Core\Routing\RouteMatch $route_match
+ * The current routing match.
+ *
+ * @return \Drupal\Core\Access\AccessResultInterface
+ * Whether we can or cannot show the "thing".
+ */
+ public function mediaAccess(RouteMatch $route_match) : AccessResultInterface {
+ return AccessResult::allowedIf($this->utils->canCreateIslandoraEntity('media', 'media_type'));
+ }
+
+}
diff --git a/src/Form/AddChildrenWizard/ChildBatchProcessor.php b/src/Form/AddChildrenWizard/ChildBatchProcessor.php
new file mode 100644
index 000000000..084e7816e
--- /dev/null
+++ b/src/Form/AddChildrenWizard/ChildBatchProcessor.php
@@ -0,0 +1,57 @@
+entityTypeManager->getStorage('taxonomy_term');
+ $node_storage = $this->entityTypeManager->getStorage('node');
+ $parent = $node_storage->load($values['node']);
+
+ // Create a node (with the filename?) (and also belonging to the target
+ // node).
+ /** @var \Drupal\node\NodeInterface $node */
+ $node = $node_storage->create([
+ 'type' => $values['bundle'],
+ 'title' => $this->getName($info, $values),
+ IslandoraUtils::MEMBER_OF_FIELD => $parent,
+ 'uid' => $this->currentUser->id(),
+ 'status' => NodeInterface::PUBLISHED,
+ IslandoraUtils::MODEL_FIELD => ($values['model'] ?
+ $taxonomy_term_storage->load($values['model']) :
+ NULL),
+ ]);
+
+ if ($node->save() !== SAVED_NEW) {
+ throw new \Exception("Failed to create node.");
+ }
+
+ return $node;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function batchProcessFinished($success, $results, $operations): void {
+ if ($success) {
+ $this->messenger->addMessage($this->formatPlural(
+ $results['count'],
+ 'Added 1 child node.',
+ 'Added @count child nodes.'
+ ));
+ }
+
+ parent::batchProcessFinished($success, $results, $operations);
+ }
+
+}
diff --git a/src/Form/AddChildrenWizard/ChildFileSelectionForm.php b/src/Form/AddChildrenWizard/ChildFileSelectionForm.php
new file mode 100644
index 000000000..9783d0823
--- /dev/null
+++ b/src/Form/AddChildrenWizard/ChildFileSelectionForm.php
@@ -0,0 +1,32 @@
+getTemporaryValue('wizard');
+ $form_state->setRedirectUrl(Url::fromUri("internal:/node/{$cached_values['node']}/members"));
+ }
+
+}
diff --git a/src/Form/AddChildrenWizard/ChildForm.php b/src/Form/AddChildrenWizard/ChildForm.php
new file mode 100644
index 000000000..0b9a197f4
--- /dev/null
+++ b/src/Form/AddChildrenWizard/ChildForm.php
@@ -0,0 +1,24 @@
+ $this->currentUser->id(),
+ '{nodeid}' => $this->nodeId,
+ ]);
+ }
+
+}
diff --git a/src/Form/AddChildrenWizard/ChildTypeSelectionForm.php b/src/Form/AddChildrenWizard/ChildTypeSelectionForm.php
new file mode 100644
index 000000000..f57959971
--- /dev/null
+++ b/src/Form/AddChildrenWizard/ChildTypeSelectionForm.php
@@ -0,0 +1,157 @@
+nodeBundleOptions === NULL) {
+ $this->nodeBundleOptions = [];
+ $this->nodeBundleHasModelField = [];
+
+ $access_handler = $this->entityTypeManager->getAccessControlHandler('node');
+ foreach ($this->entityTypeBundleInfo->getBundleInfo('node') as $bundle => $info) {
+ $access = $access_handler->createAccess(
+ $bundle,
+ NULL,
+ [],
+ TRUE
+ );
+ $this->cacheableMetadata->addCacheableDependency($access);
+ if (!$access->isAllowed()) {
+ continue;
+ }
+ $this->nodeBundleOptions[$bundle] = $info['label'];
+ $fields = $this->entityFieldManager->getFieldDefinitions('node', $bundle);
+ $this->nodeBundleHasModelField[$bundle] = array_key_exists(IslandoraUtils::MODEL_FIELD, $fields);
+ }
+ }
+
+ return $this->nodeBundleOptions;
+ }
+
+ /**
+ * Generates a mapping of taxonomy term IDs to their names.
+ *
+ * @return \Generator
+ * The mapping of taxonomy term IDs to their names.
+ *
+ * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+ * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+ */
+ protected function getModelOptions() : \Generator {
+ $terms = $this->entityTypeManager->getStorage('taxonomy_term')
+ ->loadTree('islandora_models', 0, NULL, TRUE);
+ foreach ($terms as $term) {
+ yield $term->id() => $term->getName();
+ }
+ }
+
+ /**
+ * Helper; map node bundles supporting the "has model" field, for #states.
+ *
+ * @return \Generator
+ * Yields associative array mapping the string 'value' to the bundles which
+ * have the given field.
+ */
+ protected function mapModelStates() : \Generator {
+ $this->getNodeBundleOptions();
+ foreach (array_keys(array_filter($this->nodeBundleHasModelField)) as $bundle) {
+ yield ['value' => $bundle];
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $this->cacheableMetadata = CacheableMetadata::createFromRenderArray($form)
+ ->addCacheContexts([
+ 'url',
+ 'url.query_args',
+ ]);
+ $cached_values = $form_state->getTemporaryValue('wizard');
+
+ $form['bundle'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Content Type'),
+ '#description' => $this->t('Each child created will have this content type.'),
+ '#empty_value' => '',
+ '#default_value' => $cached_values['bundle'] ?? '',
+ '#options' => $this->getNodeBundleOptions(),
+ '#required' => TRUE,
+ ];
+
+ $model_states = iterator_to_array($this->mapModelStates());
+ $form['model'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Model'),
+ '#description' => $this->t('Each child will be tagged with this model.'),
+ '#options' => iterator_to_array($this->getModelOptions()),
+ '#empty_value' => '',
+ '#default_value' => $cached_values['model'] ?? '',
+ '#states' => [
+ 'visible' => [
+ ':input[name="bundle"]' => $model_states,
+ ],
+ 'required' => [
+ ':input[name="bundle"]' => $model_states,
+ ],
+ ],
+ ];
+
+ $this->cacheableMetadata->applyTo($form);
+ return parent::buildForm($form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static function keysToSave() : array {
+ return array_merge(
+ parent::keysToSave(),
+ [
+ 'bundle',
+ 'model',
+ ]
+ );
+ }
+
+}
diff --git a/src/Form/AddChildrenWizard/FieldTrait.php b/src/Form/AddChildrenWizard/FieldTrait.php
new file mode 100644
index 000000000..830f95cda
--- /dev/null
+++ b/src/Form/AddChildrenWizard/FieldTrait.php
@@ -0,0 +1,66 @@
+getMediaType($values);
+ $media_source = $media_type->getSource();
+ $source_field = $media_source->getSourceFieldDefinition($media_type);
+
+ $fields = $this->entityFieldManager()->getFieldDefinitions('media', $media_type->id());
+
+ return $fields[$source_field->getFieldStorageDefinition()->getName()] ??
+ $media_source->createSourceField($media_type);
+ }
+
+ /**
+ * Lazy-initialization of the entity field manager service.
+ *
+ * @return \Drupal\Core\Entity\EntityFieldManagerInterface
+ * The entity field manager service.
+ */
+ protected function entityFieldManager() : EntityFieldManagerInterface {
+ if ($this->entityFieldManager === NULL) {
+ $this->setEntityFieldManager(\Drupal::service('entity_field.manager'));
+ }
+ return $this->entityFieldManager;
+ }
+
+ /**
+ * Setter for entity field manager.
+ */
+ public function setEntityFieldManager(EntityFieldManagerInterface $entity_field_manager) : self {
+ $this->entityFieldManager = $entity_field_manager;
+ return $this;
+ }
+
+}
diff --git a/src/Form/AddChildrenWizard/MediaBatchProcessor.php b/src/Form/AddChildrenWizard/MediaBatchProcessor.php
new file mode 100644
index 000000000..9a54f03b6
--- /dev/null
+++ b/src/Form/AddChildrenWizard/MediaBatchProcessor.php
@@ -0,0 +1,34 @@
+entityTypeManager->getStorage('node')->load($values['node']);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function batchProcessFinished($success, $results, $operations): void {
+ if ($success) {
+ $this->messenger->addMessage($this->formatPlural(
+ $results['count'],
+ 'Added 1 media.',
+ 'Added @count media.'
+ ));
+ }
+
+ parent::batchProcessFinished($success, $results, $operations);
+ }
+
+}
diff --git a/src/Form/AddChildrenWizard/MediaFileSelectionForm.php b/src/Form/AddChildrenWizard/MediaFileSelectionForm.php
new file mode 100644
index 000000000..534c73093
--- /dev/null
+++ b/src/Form/AddChildrenWizard/MediaFileSelectionForm.php
@@ -0,0 +1,32 @@
+getTemporaryValue('wizard');
+ $form_state->setRedirectUrl(Url::fromUri("internal:/node/{$cached_values['node']}/media"));
+ }
+
+}
diff --git a/src/Form/AddChildrenWizard/MediaForm.php b/src/Form/AddChildrenWizard/MediaForm.php
new file mode 100644
index 000000000..2e6fa2177
--- /dev/null
+++ b/src/Form/AddChildrenWizard/MediaForm.php
@@ -0,0 +1,24 @@
+ $this->currentUser->id(),
+ '{nodeid}' => $this->nodeId,
+ ]);
+ }
+
+}
diff --git a/src/Form/AddChildrenWizard/MediaTypeSelectionForm.php b/src/Form/AddChildrenWizard/MediaTypeSelectionForm.php
new file mode 100644
index 000000000..b06d004dc
--- /dev/null
+++ b/src/Form/AddChildrenWizard/MediaTypeSelectionForm.php
@@ -0,0 +1,227 @@
+entityTypeBundleInfo = $container->get('entity_type.bundle.info');
+ $instance->entityTypeManager = $container->get('entity_type.manager');
+ $instance->entityFieldManager = $container->get('entity_field.manager');
+ $instance->utils = $container->get('islandora.utils');
+
+ return $instance;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() : string {
+ return 'islandora_add_media_type_selection';
+ }
+
+ /**
+ * Memoization for ::getMediaBundleOptions().
+ *
+ * @var array|null
+ */
+ protected ?array $mediaBundleOptions = NULL;
+
+ /**
+ * Indicate presence of usage field on media bundles.
+ *
+ * Populated as a side effect in ::getMediaBundleOptions().
+ *
+ * @var array|null
+ */
+ protected ?array $mediaBundleUsageField = NULL;
+
+ /**
+ * Helper; get options for media types.
+ *
+ * @return array
+ * An associative array mapping the machine name of the media type to its
+ * human-readable label.
+ */
+ protected function getMediaBundleOptions() : array {
+ if ($this->mediaBundleOptions === NULL) {
+ $this->mediaBundleOptions = [];
+ $this->mediaBundleUsageField = [];
+
+ $access_handler = $this->entityTypeManager->getAccessControlHandler('media');
+ foreach ($this->entityTypeBundleInfo->getBundleInfo('media') as $bundle => $info) {
+ if (!$this->utils->isIslandoraType('media', $bundle)) {
+ continue;
+ }
+ $access = $access_handler->createAccess(
+ $bundle,
+ NULL,
+ [],
+ TRUE
+ );
+ $this->cacheableMetadata->addCacheableDependency($access);
+ if (!$access->isAllowed()) {
+ continue;
+ }
+ $this->mediaBundleOptions[$bundle] = $info['label'];
+ $fields = $this->entityFieldManager->getFieldDefinitions('media', $bundle);
+ $this->mediaBundleUsageField[$bundle] = array_key_exists(IslandoraUtils::MEDIA_USAGE_FIELD, $fields);
+ }
+ }
+
+ return $this->mediaBundleOptions;
+ }
+
+ /**
+ * Helper; list the terms of the "islandora_media_use" vocabulary.
+ *
+ * @return \Generator
+ * Generates term IDs as keys mapping to term names.
+ *
+ * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+ * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+ */
+ protected function getMediaUseOptions() : \Generator {
+ /** @var \Drupal\taxonomy\TermInterface[] $terms */
+ $terms = $this->entityTypeManager->getStorage('taxonomy_term')
+ ->loadTree('islandora_media_use', 0, NULL, TRUE);
+
+ foreach ($terms as $term) {
+ yield $term->id() => $term->getName();
+ }
+ }
+
+ /**
+ * Helper; map media types supporting the usage field for use with #states.
+ *
+ * @return \Generator
+ * Yields associative array mapping the string 'value' to the bundles which
+ * have the given field.
+ */
+ protected function mapUseStates(): \Generator {
+ $this->getMediaBundleOptions();
+ foreach (array_keys(array_filter($this->mediaBundleUsageField)) as $bundle) {
+ yield ['value' => $bundle];
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $this->cacheableMetadata = CacheableMetadata::createFromRenderArray($form)
+ ->addCacheContexts([
+ 'url',
+ 'url.query_args',
+ ]);
+ $cached_values = $form_state->getTemporaryValue('wizard');
+
+ $form['media_type'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Media Type'),
+ '#description' => $this->t('Each media created will have this type.'),
+ '#empty_value' => '',
+ '#default_value' => $cached_values['media_type'] ?? '',
+ '#options' => $this->getMediaBundleOptions(),
+ '#required' => TRUE,
+ ];
+ $use_states = iterator_to_array($this->mapUseStates());
+ $form['use'] = [
+ '#type' => 'checkboxes',
+ '#title' => $this->t('Usage'),
+ '#description' => $this->t('Defined by Portland Common Data Model: Use Extension. "Original File" will trigger creation of derivatives.', [
+ ':url' => 'https://pcdm.org/2015/05/12/use',
+ ]),
+ '#options' => iterator_to_array($this->getMediaUseOptions()),
+ '#default_value' => $cached_values['use'] ?? [],
+ '#states' => [
+ 'visible' => [
+ ':input[name="media_type"]' => $use_states,
+ ],
+ 'required' => [
+ ':input[name="media_type"]' => $use_states,
+ ],
+ ],
+ ];
+
+ $this->cacheableMetadata->applyTo($form);
+ return $form;
+ }
+
+ /**
+ * Helper; enumerate keys to persist in form state.
+ *
+ * @return string[]
+ * The keys to be persisted in our temp value in form state.
+ */
+ protected static function keysToSave() : array {
+ return [
+ 'media_type',
+ 'use',
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $cached_values = $form_state->getTemporaryValue('wizard');
+ foreach (static::keysToSave() as $key) {
+ $cached_values[$key] = $form_state->getValue($key);
+ }
+ $form_state->setTemporaryValue('wizard', $cached_values);
+ }
+
+}
diff --git a/src/Form/AddChildrenWizard/MediaTypeTrait.php b/src/Form/AddChildrenWizard/MediaTypeTrait.php
new file mode 100644
index 000000000..36cf6ff2a
--- /dev/null
+++ b/src/Form/AddChildrenWizard/MediaTypeTrait.php
@@ -0,0 +1,58 @@
+entityTypeManager()->getStorage('media_type')->load($values['media_type']);
+ }
+
+ /**
+ * Lazy-initialization of the entity type manager service.
+ *
+ * @return \Drupal\Core\Entity\EntityTypeManagerInterface
+ * The entity type manager service.
+ */
+ protected function entityTypeManager() : EntityTypeManagerInterface {
+ if ($this->entityTypeManager === NULL) {
+ $this->setEntityTypeManager(\Drupal::service('entity_type.manager'));
+ }
+ return $this->entityTypeManager;
+ }
+
+ /**
+ * Setter for the entity type manager service.
+ */
+ public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) : self {
+ $this->entityTypeManager = $entity_type_manager;
+ return $this;
+ }
+
+}
diff --git a/src/Form/AddChildrenWizard/WizardTrait.php b/src/Form/AddChildrenWizard/WizardTrait.php
new file mode 100644
index 000000000..dd56450fa
--- /dev/null
+++ b/src/Form/AddChildrenWizard/WizardTrait.php
@@ -0,0 +1,40 @@
+widgetPluginManager->getInstance([
+ 'field_definition' => $field,
+ 'form_mode' => 'default',
+ 'prepare' => TRUE,
+ ]);
+ }
+
+}