diff --git a/config/install/ocfl_export.settings.yml b/config/install/ocfl_export.settings.yml index cc04d27..4436b48 100644 --- a/config/install/ocfl_export.settings.yml +++ b/config/install/ocfl_export.settings.yml @@ -4,3 +4,7 @@ content_types: [] auto_export_media_enabled: true media_types: [] ocfl_version: '1.1' +include_files: true +member_of_field: field_member_of +media_of_field: field_media_of +model_field: field_model diff --git a/config/schema/ocfl_export.schema.yml b/config/schema/ocfl_export.schema.yml index 363a125..16d0b4c 100644 --- a/config/schema/ocfl_export.schema.yml +++ b/config/schema/ocfl_export.schema.yml @@ -26,3 +26,15 @@ ocfl_export.settings: ocfl_version: type: string label: 'OCFL version' + include_files: + type: boolean + label: 'Copy media and file binaries into OCFL objects' + member_of_field: + type: string + label: 'Node field holding parent membership (isMemberOf)' + media_of_field: + type: string + label: 'Media field pointing at the owning node' + model_field: + type: string + label: 'Node field holding the model taxonomy term' diff --git a/ocfl_export.install b/ocfl_export.install new file mode 100644 index 0000000..951e7d7 --- /dev/null +++ b/ocfl_export.install @@ -0,0 +1,27 @@ +getEditable('ocfl_export.settings'); + $defaults = [ + 'include_files' => TRUE, + 'member_of_field' => 'field_member_of', + 'media_of_field' => 'field_media_of', + 'model_field' => 'field_model', + ]; + foreach ($defaults as $key => $value) { + if ($config->get($key) === NULL) { + $config->set($key, $value); + } + } + $config->save(); +} diff --git a/ocfl_export.module b/ocfl_export.module index 94ae6d7..0363c2a 100644 --- a/ocfl_export.module +++ b/ocfl_export.module @@ -12,33 +12,30 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Url; use Drupal\media\MediaInterface; use Drupal\node\NodeInterface; +use Drupal\ocfl_export\OcflExporter; /** * Implements hook_node_insert(). */ function ocfl_export_node_insert(NodeInterface $node): void { - _ocfl_export_save_node($node); + _ocfl_export_exporter()->queueNode($node); } /** * Implements hook_node_update(). */ function ocfl_export_node_update(NodeInterface $node): void { - _ocfl_export_save_node($node); + _ocfl_export_exporter()->queueNode($node); } /** * Implements hook_node_delete(). */ function ocfl_export_node_delete(NodeInterface $node): void { - $config = \Drupal::config('ocfl_export.settings'); + $exporter = _ocfl_export_exporter(); + $exporter->unqueueNode($node); - if (!$config->get('auto_export_enabled')) { - return; - } - - $content_types = $config->get('content_types') ?: []; - if (!empty($content_types) && !in_array($node->bundle(), $content_types, TRUE)) { + if (!$exporter->shouldAutoExportNode($node)) { return; } @@ -55,38 +52,6 @@ function ocfl_export_node_delete(NodeInterface $node): void { } } -/** - * Helper function to save node to OCFL storage. - */ -function _ocfl_export_save_node(NodeInterface $node): void { - $config = \Drupal::config('ocfl_export.settings'); - - if (!$config->get('auto_export_enabled')) { - return; - } - - $content_types = $config->get('content_types') ?: []; - if (!empty($content_types) && !in_array($node->bundle(), $content_types, TRUE)) { - return; - } - - try { - /** @var \Drupal\ocfl_export\NodeSerializerService $serializer */ - $serializer = \Drupal::service('ocfl_export.node_serializer'); - /** @var \Drupal\ocfl_export\OcflStorageService $storage */ - $storage = \Drupal::service('ocfl_export.storage'); - - $data = $serializer->serialize($node); - $storage->saveObject($node->uuid(), $data); - } - catch (\Exception $e) { - \Drupal::logger('ocfl_export')->error('Failed to export node @nid to OCFL: @message', [ - '@nid' => $node->id(), - '@message' => $e->getMessage(), - ]); - } -} - /** * Implements hook_media_insert(). */ @@ -105,14 +70,12 @@ function ocfl_export_media_update(MediaInterface $media): void { * Implements hook_media_delete(). */ function ocfl_export_media_delete(MediaInterface $media): void { - $config = \Drupal::config('ocfl_export.settings'); + $exporter = _ocfl_export_exporter(); - if (!$config->get('auto_export_media_enabled')) { - return; - } + // The owning node's object should no longer include this media. + $exporter->queueParentNodes($media); - $media_types = $config->get('media_types') ?: []; - if (!empty($media_types) && !in_array($media->bundle(), $media_types, TRUE)) { + if (!$exporter->shouldAutoExportMedia($media)) { return; } @@ -133,25 +96,17 @@ function ocfl_export_media_delete(MediaInterface $media): void { * Helper function to save media to OCFL storage. */ function _ocfl_export_save_media(MediaInterface $media): void { - $config = \Drupal::config('ocfl_export.settings'); + $exporter = _ocfl_export_exporter(); - if (!$config->get('auto_export_media_enabled')) { - return; - } + // Islandora media (and derivatives) belong to a node's object. + $exporter->queueParentNodes($media); - $media_types = $config->get('media_types') ?: []; - if (!empty($media_types) && !in_array($media->bundle(), $media_types, TRUE)) { + if (!$exporter->shouldAutoExportMedia($media)) { return; } try { - /** @var \Drupal\ocfl_export\MediaSerializerService $serializer */ - $serializer = \Drupal::service('ocfl_export.media_serializer'); - /** @var \Drupal\ocfl_export\OcflStorageService $storage */ - $storage = \Drupal::service('ocfl_export.storage'); - - $data = $serializer->serialize($media); - $storage->saveObject($media->uuid(), $data); + $exporter->exportMedia($media); } catch (\Exception $e) { \Drupal::logger('ocfl_export')->error('Failed to export media @mid to OCFL: @message', [ @@ -161,6 +116,13 @@ function _ocfl_export_save_media(MediaInterface $media): void { } } +/** + * Get the OCFL exporter service. + */ +function _ocfl_export_exporter(): OcflExporter { + return \Drupal::service('ocfl_export.exporter'); +} + /** * Implements hook_node_view(). */ diff --git a/ocfl_export.services.yml b/ocfl_export.services.yml index 336b315..c7ed67b 100644 --- a/ocfl_export.services.yml +++ b/ocfl_export.services.yml @@ -19,3 +19,16 @@ services: arguments: - '@entity_type.manager' - '@datetime.time' + + ocfl_export.exporter: + class: Drupal\ocfl_export\OcflExporter + arguments: + - '@entity_type.manager' + - '@entity_field.manager' + - '@config.factory' + - '@ocfl_export.storage' + - '@ocfl_export.node_serializer' + - '@ocfl_export.media_serializer' + - '@logger.factory' + tags: + - { name: needs_destruction } diff --git a/src/Controller/OcflExportController.php b/src/Controller/OcflExportController.php index bc7046a..9e673a0 100644 --- a/src/Controller/OcflExportController.php +++ b/src/Controller/OcflExportController.php @@ -9,8 +9,7 @@ use Drupal\Core\Messenger\MessengerInterface; use Drupal\Core\Url; use Drupal\media\MediaInterface; use Drupal\node\NodeInterface; -use Drupal\ocfl_export\MediaSerializerService; -use Drupal\ocfl_export\NodeSerializerService; +use Drupal\ocfl_export\OcflExporter; use Drupal\ocfl_export\OcflStorageService; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -24,8 +23,7 @@ class OcflExportController extends ControllerBase { public function __construct( protected OcflStorageService $storageService, - protected NodeSerializerService $nodeSerializerService, - protected MediaSerializerService $mediaSerializerService, + protected OcflExporter $exporter, MessengerInterface $messenger, ) { $this->messenger = $messenger; @@ -37,8 +35,7 @@ class OcflExportController extends ControllerBase { public static function create(ContainerInterface $container): static { return new static( $container->get('ocfl_export.storage'), - $container->get('ocfl_export.node_serializer'), - $container->get('ocfl_export.media_serializer'), + $container->get('ocfl_export.exporter'), $container->get('messenger'), ); } @@ -54,8 +51,7 @@ class OcflExportController extends ControllerBase { */ public function exportNode(NodeInterface $node): RedirectResponse { try { - $data = $this->nodeSerializerService->serialize($node); - $success = $this->storageService->saveObject($node->uuid(), $data); + $success = $this->exporter->exportNode($node, 'Manual export'); if ($success) { $this->messenger->addStatus($this->t('Node "@title" has been exported to OCFL storage.', [ @@ -100,8 +96,7 @@ class OcflExportController extends ControllerBase { */ public function exportMedia(MediaInterface $media): RedirectResponse { try { - $data = $this->mediaSerializerService->serialize($media); - $success = $this->storageService->saveObject($media->uuid(), $data); + $success = $this->exporter->exportMedia($media, 'Manual export'); if ($success) { $this->messenger->addStatus($this->t('Media "@name" has been exported to OCFL storage.', [ diff --git a/src/Form/OcflSettingsForm.php b/src/Form/OcflSettingsForm.php index 7a0656a..9cdc597 100644 --- a/src/Form/OcflSettingsForm.php +++ b/src/Form/OcflSettingsForm.php @@ -155,6 +155,41 @@ class OcflSettingsForm extends ConfigFormBase { '#default_value' => $config->get('media_types') ?: [], ]; + // Files and relationships. + $form['content'] = [ + '#type' => 'details', + '#title' => $this->t('Files and Relationships'), + '#open' => TRUE, + ]; + + $form['content']['include_files'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Copy files into OCFL objects'), + '#description' => $this->t('Copy the binaries of associated media and of file/image fields into each object. Unchanged files are not copied again in later versions. When unchecked, only metadata is exported.'), + '#default_value' => $config->get('include_files') ?? TRUE, + ]; + + $form['content']['media_of_field'] = [ + '#type' => 'textfield', + '#title' => $this->t('"Media of" field'), + '#description' => $this->t('Media field that references the node the media belongs to (Islandora: field_media_of). Media found through it are included in the OCFL object of that node. Leave empty to disable.'), + '#default_value' => $config->get('media_of_field'), + ]; + + $form['content']['member_of_field'] = [ + '#type' => 'textfield', + '#title' => $this->t('"Member of" field'), + '#description' => $this->t('Node field that references parent nodes (Islandora: field_member_of). Used to record direct parents and the full collection hierarchy. Leave empty to disable.'), + '#default_value' => $config->get('member_of_field'), + ]; + + $form['content']['model_field'] = [ + '#type' => 'textfield', + '#title' => $this->t('Model field'), + '#description' => $this->t('Node field that references the model term (Islandora: field_model). Leave empty to disable.'), + '#default_value' => $config->get('model_field'), + ]; + // Initialize button. $form['actions']['initialize'] = [ '#type' => 'submit', @@ -196,6 +231,10 @@ class OcflSettingsForm extends ConfigFormBase { ->set('auto_export_media_enabled', (bool) $form_state->getValue('auto_export_media_enabled')) ->set('media_types', array_values($mediaTypes)) ->set('ocfl_version', $form_state->getValue('ocfl_version')) + ->set('include_files', (bool) $form_state->getValue('include_files')) + ->set('media_of_field', trim((string) $form_state->getValue('media_of_field'))) + ->set('member_of_field', trim((string) $form_state->getValue('member_of_field'))) + ->set('model_field', trim((string) $form_state->getValue('model_field'))) ->save(); parent::submitForm($form, $form_state); diff --git a/src/OcflExporter.php b/src/OcflExporter.php new file mode 100644 index 0000000..a9148da --- /dev/null +++ b/src/OcflExporter.php @@ -0,0 +1,390 @@ +logger = $loggerFactory->get('ocfl_export'); + } + + /** + * Export a node, its associated media and its files as one OCFL object. + * + * @param \Drupal\node\NodeInterface $node + * The node. + * @param string|null $message + * Optional OCFL version message. + * + * @return bool + * TRUE on success. + */ + public function exportNode(NodeInterface $node, ?string $message = NULL): bool { + $data = $this->nodeSerializer->serialize($node); + $files = []; + + $data['files'] = $this->collectEntityFiles($node, 'files', TRUE, $files); + + $data['media'] = []; + foreach ($this->getAssociatedMedia($node) as $item) { + /** @var \Drupal\media\MediaInterface $media */ + $media = $item['media']; + $mediaData = $this->mediaSerializer->serialize($media); + unset($mediaData['_metadata']); + $mediaData['association'] = $item['association']; + $mediaData['files'] = $this->collectEntityFiles($media, 'media/' . $media->uuid(), FALSE, $files, ['thumbnail']); + $data['media'][] = $mediaData; + } + + if (!$this->includeFiles()) { + $files = []; + } + + return $this->save($node, $data, $files, $message); + } + + /** + * Export a media entity and its files as its own OCFL object. + * + * @param \Drupal\media\MediaInterface $media + * The media entity. + * @param string|null $message + * Optional OCFL version message. + * + * @return bool + * TRUE on success. + */ + public function exportMedia(MediaInterface $media, ?string $message = NULL): bool { + $data = $this->mediaSerializer->serialize($media); + $files = []; + $data['files'] = $this->collectEntityFiles($media, 'files', TRUE, $files, ['thumbnail']); + + if (!$this->includeFiles()) { + $files = []; + } + + return $this->save($media, $data, $files, $message); + } + + /** + * Save an entity's OCFL object and refresh its rendered export status. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The exported entity. + * @param array $data + * The metadata. + * @param array $files + * Map of logical path => file URI. + * @param string|null $message + * Optional OCFL version message. + * + * @return bool + * TRUE on success. + */ + protected function save(EntityInterface $entity, array $data, array $files, ?string $message): bool { + $saved = $this->storage->saveObject($entity->uuid(), $data, $files, $message); + if ($saved) { + // The export status block is part of the cached entity render. + Cache::invalidateTags($entity->getCacheTagsToInvalidate()); + } + return $saved; + } + + /** + * Queue a node for automatic export if the settings allow it. + * + * @param \Drupal\node\NodeInterface $node + * The node. + */ + public function queueNode(NodeInterface $node): void { + if ($this->shouldAutoExportNode($node)) { + $this->queue[(int) $node->id()] = (int) $node->id(); + } + } + + /** + * Queue the nodes a media entity belongs to (before and after the change). + * + * @param \Drupal\media\MediaInterface $media + * The media entity being inserted, updated or deleted. + */ + public function queueParentNodes(MediaInterface $media): void { + $field = $this->configFactory->get('ocfl_export.settings')->get('media_of_field'); + if (!$field || !$media->hasField($field)) { + return; + } + + $parents = $media->get($field)->referencedEntities(); + $original = method_exists($media, 'getOriginal') ? $media->getOriginal() : ($media->original ?? NULL); + if ($original instanceof MediaInterface && $original->hasField($field)) { + $parents = array_merge($parents, $original->get($field)->referencedEntities()); + } + + foreach ($parents as $parent) { + if ($parent instanceof NodeInterface) { + $this->queueNode($parent); + } + } + } + + /** + * Remove a node from the export queue (e.g. because it was deleted). + * + * @param \Drupal\node\NodeInterface $node + * The node. + */ + public function unqueueNode(NodeInterface $node): void { + unset($this->queue[(int) $node->id()]); + } + + /** + * {@inheritdoc} + */ + public function destruct(): void { + if (!$this->queue) { + return; + } + + $nids = $this->queue; + $this->queue = []; + $nodes = $this->entityTypeManager->getStorage('node')->loadMultiple($nids); + + foreach ($nodes as $node) { + try { + if (!$this->exportNode($node)) { + $this->logger->error('Failed to export node @nid to OCFL.', ['@nid' => $node->id()]); + } + } + catch (\Exception $e) { + $this->logger->error('Failed to export node @nid to OCFL: @message', [ + '@nid' => $node->id(), + '@message' => $e->getMessage(), + ]); + } + } + } + + /** + * Whether a node should be exported automatically. + * + * @param \Drupal\node\NodeInterface $node + * The node. + * + * @return bool + * TRUE if automatic export is enabled for the node's bundle. + */ + public function shouldAutoExportNode(NodeInterface $node): bool { + $config = $this->configFactory->get('ocfl_export.settings'); + if (!$config->get('auto_export_enabled')) { + return FALSE; + } + $types = $config->get('content_types') ?: []; + return empty($types) || in_array($node->bundle(), $types, TRUE); + } + + /** + * Whether a media entity should be exported automatically. + * + * @param \Drupal\media\MediaInterface $media + * The media entity. + * + * @return bool + * TRUE if automatic export is enabled for the media's bundle. + */ + public function shouldAutoExportMedia(MediaInterface $media): bool { + $config = $this->configFactory->get('ocfl_export.settings'); + if (!$config->get('auto_export_media_enabled')) { + return FALSE; + } + $types = $config->get('media_types') ?: []; + return empty($types) || in_array($media->bundle(), $types, TRUE); + } + + /** + * Find the media associated with a node. + * + * @param \Drupal\node\NodeInterface $node + * The node. + * + * @return array + * Keyed by media ID; each value has 'media' (the entity) and + * 'association' (a list such as ['media_of', 'field:field_image']). + */ + protected function getAssociatedMedia(NodeInterface $node): array { + $associated = []; + $mediaStorage = $this->entityTypeManager->getStorage('media'); + + // Islandora: media point at the node they belong to. + $mediaOfField = $this->configFactory->get('ocfl_export.settings')->get('media_of_field'); + $mediaFieldStorage = $this->entityFieldManager->getFieldStorageDefinitions('media'); + if ($mediaOfField && isset($mediaFieldStorage[$mediaOfField])) { + $mids = $mediaStorage->getQuery() + ->accessCheck(FALSE) + ->condition($mediaOfField, $node->id()) + ->sort('mid') + ->execute(); + foreach ($mediaStorage->loadMultiple($mids) as $mid => $media) { + $associated[$mid] = ['media' => $media, 'association' => ['media_of']]; + } + } + + // Plain Drupal: the node references media from its own fields. + foreach ($node->getFieldDefinitions() as $fieldName => $definition) { + if ($definition->getType() !== 'entity_reference' || $definition->getSetting('target_type') !== 'media') { + continue; + } + foreach ($node->get($fieldName)->referencedEntities() as $media) { + $mid = (int) $media->id(); + $associated[$mid]['media'] = $media; + $associated[$mid]['association'][] = 'field:' . $fieldName; + } + } + + ksort($associated); + return $associated; + } + + /** + * Collect the files held in an entity's file and image fields. + * + * @param \Drupal\Core\Entity\FieldableEntityInterface $entity + * The entity. + * @param string $prefix + * Logical directory the files are placed under. + * @param bool $groupByField + * Whether to add a sub-directory per field. + * @param array $files + * Map of logical path => file URI, added to in place. + * @param string[] $skipFields + * Field names to ignore. + * + * @return array + * Descriptions of the files for inclusion in node.json. + */ + protected function collectEntityFiles(FieldableEntityInterface $entity, string $prefix, bool $groupByField, array &$files, array $skipFields = []): array { + $descriptions = []; + + foreach ($entity->getFieldDefinitions() as $fieldName => $definition) { + if (!in_array($definition->getType(), static::FILE_FIELD_TYPES, TRUE) || in_array($fieldName, $skipFields, TRUE)) { + continue; + } + + foreach ($entity->get($fieldName) as $delta => $item) { + $file = $item->entity; + if (!$file instanceof FileInterface) { + continue; + } + + $dir = $groupByField ? $prefix . '/' . $fieldName : $prefix; + $logicalPath = $this->uniqueLogicalPath($dir, $file, $files); + $files[$logicalPath] = $file->getFileUri(); + + $descriptions[] = [ + 'logical_path' => $this->includeFiles() ? $logicalPath : NULL, + 'field' => $fieldName, + 'delta' => $delta, + 'fid' => (int) $file->id(), + 'uuid' => $file->uuid(), + 'filename' => $file->getFilename(), + 'filemime' => $file->getMimeType(), + 'filesize' => (int) $file->getSize(), + 'uri' => $file->getFileUri(), + ]; + } + } + + return $descriptions; + } + + /** + * Build a safe logical path that doesn't collide with one already used. + * + * @param string $dir + * The logical directory. + * @param \Drupal\file\FileInterface $file + * The file. + * @param array $files + * Logical paths already in use, as keys. + * + * @return string + * The logical path. + */ + protected function uniqueLogicalPath(string $dir, FileInterface $file, array $files): string { + $name = preg_replace('/[\x00-\x1F\x7F\/\\\\]/u', '_', trim((string) $file->getFilename())); + if ($name === '' || $name === '.' || $name === '..') { + $name = 'file-' . $file->id(); + } + + $path = $dir . '/' . $name; + if (!isset($files[$path])) { + return $path; + } + + $info = pathinfo($name); + $extension = isset($info['extension']) ? '.' . $info['extension'] : ''; + return $dir . '/' . $info['filename'] . '-' . $file->id() . $extension; + } + + /** + * Whether binary files should be copied into objects. + */ + protected function includeFiles(): bool { + return (bool) ($this->configFactory->get('ocfl_export.settings')->get('include_files') ?? TRUE); + } + +} diff --git a/src/Plugin/Action/ExportMediaToOcfl.php b/src/Plugin/Action/ExportMediaToOcfl.php index 08f0d86..a12708d 100644 --- a/src/Plugin/Action/ExportMediaToOcfl.php +++ b/src/Plugin/Action/ExportMediaToOcfl.php @@ -8,8 +8,7 @@ use Drupal\Core\Action\ActionBase; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Session\AccountInterface; use Drupal\media\MediaInterface; -use Drupal\ocfl_export\MediaSerializerService; -use Drupal\ocfl_export\OcflStorageService; +use Drupal\ocfl_export\OcflExporter; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -29,8 +28,7 @@ class ExportMediaToOcfl extends ActionBase implements ContainerFactoryPluginInte array $configuration, $plugin_id, $plugin_definition, - protected OcflStorageService $storageService, - protected MediaSerializerService $serializerService, + protected OcflExporter $exporter, protected LoggerInterface $logger, ) { parent::__construct($configuration, $plugin_id, $plugin_definition); @@ -44,8 +42,7 @@ class ExportMediaToOcfl extends ActionBase implements ContainerFactoryPluginInte $configuration, $plugin_id, $plugin_definition, - $container->get('ocfl_export.storage'), - $container->get('ocfl_export.media_serializer'), + $container->get('ocfl_export.exporter'), $container->get('logger.factory')->get('ocfl_export'), ); } @@ -59,8 +56,7 @@ class ExportMediaToOcfl extends ActionBase implements ContainerFactoryPluginInte } try { - $data = $this->serializerService->serialize($media); - $success = $this->storageService->saveObject($media->uuid(), $data); + $success = $this->exporter->exportMedia($media, 'Bulk action export'); if ($success) { $this->logger->info('Batch exported media @mid (@name) to OCFL.', [ diff --git a/src/Plugin/Action/ExportNodeToOcfl.php b/src/Plugin/Action/ExportNodeToOcfl.php index aeb9ae5..6161e3b 100644 --- a/src/Plugin/Action/ExportNodeToOcfl.php +++ b/src/Plugin/Action/ExportNodeToOcfl.php @@ -8,8 +8,7 @@ use Drupal\Core\Action\ActionBase; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Session\AccountInterface; use Drupal\node\NodeInterface; -use Drupal\ocfl_export\NodeSerializerService; -use Drupal\ocfl_export\OcflStorageService; +use Drupal\ocfl_export\OcflExporter; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -29,8 +28,7 @@ class ExportNodeToOcfl extends ActionBase implements ContainerFactoryPluginInter array $configuration, $plugin_id, $plugin_definition, - protected OcflStorageService $storageService, - protected NodeSerializerService $serializerService, + protected OcflExporter $exporter, protected LoggerInterface $logger, ) { parent::__construct($configuration, $plugin_id, $plugin_definition); @@ -44,8 +42,7 @@ class ExportNodeToOcfl extends ActionBase implements ContainerFactoryPluginInter $configuration, $plugin_id, $plugin_definition, - $container->get('ocfl_export.storage'), - $container->get('ocfl_export.node_serializer'), + $container->get('ocfl_export.exporter'), $container->get('logger.factory')->get('ocfl_export'), ); } @@ -59,8 +56,7 @@ class ExportNodeToOcfl extends ActionBase implements ContainerFactoryPluginInter } try { - $data = $this->serializerService->serialize($node); - $success = $this->storageService->saveObject($node->uuid(), $data); + $success = $this->exporter->exportNode($node, 'Bulk action export'); if ($success) { $this->logger->info('Batch exported node @nid (@title) to OCFL.', [