commit a991225c04e296707b50db98908ee81f4e742dee Author: Paul Pound Date: Tue Jun 30 10:51:32 2026 -0300 first commit diff --git a/config/install/ocfl_export.settings.yml b/config/install/ocfl_export.settings.yml new file mode 100644 index 0000000..cc04d27 --- /dev/null +++ b/config/install/ocfl_export.settings.yml @@ -0,0 +1,6 @@ +storage_root: 'private://ocfl-storage' +auto_export_enabled: true +content_types: [] +auto_export_media_enabled: true +media_types: [] +ocfl_version: '1.1' diff --git a/config/install/system.action.ocfl_export_media.yml b/config/install/system.action.ocfl_export_media.yml new file mode 100644 index 0000000..6d5828b --- /dev/null +++ b/config/install/system.action.ocfl_export_media.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + module: + - media + - ocfl_export +id: ocfl_export_media +label: 'Export to OCFL' +type: media +plugin: ocfl_export_media +configuration: {} diff --git a/config/install/system.action.ocfl_export_node.yml b/config/install/system.action.ocfl_export_node.yml new file mode 100644 index 0000000..06864d1 --- /dev/null +++ b/config/install/system.action.ocfl_export_node.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + module: + - node + - ocfl_export +id: ocfl_export_node +label: 'Export to OCFL' +type: node +plugin: ocfl_export_node +configuration: {} diff --git a/config/schema/ocfl_export.schema.yml b/config/schema/ocfl_export.schema.yml new file mode 100644 index 0000000..363a125 --- /dev/null +++ b/config/schema/ocfl_export.schema.yml @@ -0,0 +1,28 @@ +ocfl_export.settings: + type: config_object + label: 'OCFL Export settings' + mapping: + storage_root: + type: string + label: 'Storage root path' + auto_export_enabled: + type: boolean + label: 'Enable automatic export on node save' + content_types: + type: sequence + label: 'Content types to export' + sequence: + type: string + label: 'Content type machine name' + auto_export_media_enabled: + type: boolean + label: 'Enable automatic export on media save' + media_types: + type: sequence + label: 'Media types to export' + sequence: + type: string + label: 'Media type machine name' + ocfl_version: + type: string + label: 'OCFL version' diff --git a/ocfl_export.info.yml b/ocfl_export.info.yml new file mode 100644 index 0000000..f862b97 --- /dev/null +++ b/ocfl_export.info.yml @@ -0,0 +1,11 @@ +name: 'OCFL Export' +type: module +description: 'Exports nodes and media as OCFL (Oxford Common File Layout) objects on the filesystem.' +package: Content +core_version_requirement: ^10.5 +php: 8.3 +dependencies: + - drupal:node + - drupal:user + - drupal:media +configure: ocfl_export.settings diff --git a/ocfl_export.links.action.yml b/ocfl_export.links.action.yml new file mode 100644 index 0000000..2a39e5e --- /dev/null +++ b/ocfl_export.links.action.yml @@ -0,0 +1,13 @@ +ocfl_export.export_node: + route_name: ocfl_export.export_node + title: 'Export to OCFL' + appears_on: + - entity.node.canonical + - entity.node.edit_form + +ocfl_export.export_media: + route_name: ocfl_export.export_media + title: 'Export to OCFL' + appears_on: + - entity.media.canonical + - entity.media.edit_form diff --git a/ocfl_export.module b/ocfl_export.module new file mode 100644 index 0000000..94ae6d7 --- /dev/null +++ b/ocfl_export.module @@ -0,0 +1,264 @@ +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\OcflStorageService $storage */ + $storage = \Drupal::service('ocfl_export.storage'); + $storage->markDeleted($node); + } + catch (\Exception $e) { + \Drupal::logger('ocfl_export')->error('Failed to mark OCFL object as deleted for node @nid: @message', [ + '@nid' => $node->id(), + '@message' => $e->getMessage(), + ]); + } +} + +/** + * 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(). + */ +function ocfl_export_media_insert(MediaInterface $media): void { + _ocfl_export_save_media($media); +} + +/** + * Implements hook_media_update(). + */ +function ocfl_export_media_update(MediaInterface $media): void { + _ocfl_export_save_media($media); +} + +/** + * Implements hook_media_delete(). + */ +function ocfl_export_media_delete(MediaInterface $media): void { + $config = \Drupal::config('ocfl_export.settings'); + + if (!$config->get('auto_export_media_enabled')) { + return; + } + + $media_types = $config->get('media_types') ?: []; + if (!empty($media_types) && !in_array($media->bundle(), $media_types, TRUE)) { + return; + } + + try { + /** @var \Drupal\ocfl_export\OcflStorageService $storage */ + $storage = \Drupal::service('ocfl_export.storage'); + $storage->markMediaDeleted($media); + } + catch (\Exception $e) { + \Drupal::logger('ocfl_export')->error('Failed to mark OCFL object as deleted for media @mid: @message', [ + '@mid' => $media->id(), + '@message' => $e->getMessage(), + ]); + } +} + +/** + * Helper function to save media to OCFL storage. + */ +function _ocfl_export_save_media(MediaInterface $media): void { + $config = \Drupal::config('ocfl_export.settings'); + + if (!$config->get('auto_export_media_enabled')) { + return; + } + + $media_types = $config->get('media_types') ?: []; + if (!empty($media_types) && !in_array($media->bundle(), $media_types, TRUE)) { + 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); + } + catch (\Exception $e) { + \Drupal::logger('ocfl_export')->error('Failed to export media @mid to OCFL: @message', [ + '@mid' => $media->id(), + '@message' => $e->getMessage(), + ]); + } +} + +/** + * Implements hook_node_view(). + */ +function ocfl_export_node_view(array &$build, NodeInterface $node, EntityViewDisplayInterface $display, string $view_mode): void { + if ($view_mode !== 'full') { + return; + } + + if (!\Drupal::currentUser()->hasPermission('export ocfl content')) { + return; + } + + $build['ocfl_export_status'] = _ocfl_export_build_status_element($node->uuid(), 'node', $node->id()); +} + +/** + * Implements hook_media_view(). + */ +function ocfl_export_media_view(array &$build, MediaInterface $media, EntityViewDisplayInterface $display, string $view_mode): void { + if ($view_mode !== 'full') { + return; + } + + if (!\Drupal::currentUser()->hasPermission('export ocfl content')) { + return; + } + + $build['ocfl_export_status'] = _ocfl_export_build_status_element($media->uuid(), 'media', $media->id()); +} + +/** + * Build the OCFL export status render element. + * + * @param string $uuid + * The entity UUID. + * @param string $entity_type + * The entity type ('node' or 'media'). + * @param int|string $entity_id + * The entity ID. + * + * @return array + * A render array for the OCFL export status. + */ +function _ocfl_export_build_status_element(string $uuid, string $entity_type, int|string $entity_id): array { + /** @var \Drupal\ocfl_export\OcflStorageService $storage */ + $storage = \Drupal::service('ocfl_export.storage'); + $status = $storage->getExportStatus($uuid); + + $element = [ + '#type' => 'container', + '#attributes' => [ + 'class' => ['ocfl-export-status'], + ], + '#weight' => -100, + ]; + + if ($status) { + $element['info'] = [ + '#type' => 'html_tag', + '#tag' => 'div', + '#attributes' => [ + 'class' => ['ocfl-export-info'], + ], + '#value' => t('OCFL Export: @version (exported @date)', [ + '@version' => $status['version'], + '@date' => $status['created'] ? \Drupal::service('date.formatter')->format(strtotime($status['created']), 'short') : t('unknown'), + ]), + ]; + + $element['link'] = [ + '#type' => 'html_tag', + '#tag' => 'div', + '#attributes' => [ + 'class' => ['ocfl-export-link'], + ], + 'content' => [ + '#type' => 'link', + '#title' => t('View exported JSON file'), + '#url' => Url::fromRoute('ocfl_export.view_export', [ + 'entity_type' => $entity_type, + 'uuid' => $uuid, + ]), + '#attributes' => [ + 'target' => '_blank', + ], + ], + ]; + } + else { + $element['info'] = [ + '#type' => 'html_tag', + '#tag' => 'div', + '#attributes' => [ + 'class' => ['ocfl-export-info', 'ocfl-not-exported'], + ], + '#value' => t('Not yet exported to OCFL'), + ]; + } + + return $element; +} diff --git a/ocfl_export.permissions.yml b/ocfl_export.permissions.yml new file mode 100644 index 0000000..120e919 --- /dev/null +++ b/ocfl_export.permissions.yml @@ -0,0 +1,8 @@ +administer ocfl export: + title: 'Administer OCFL Export' + description: 'Configure OCFL export settings including storage path and content types.' + restrict access: true + +export ocfl content: + title: 'Export content to OCFL' + description: 'Manually export nodes to OCFL storage.' diff --git a/ocfl_export.routing.yml b/ocfl_export.routing.yml new file mode 100644 index 0000000..80f71ac --- /dev/null +++ b/ocfl_export.routing.yml @@ -0,0 +1,40 @@ +ocfl_export.settings: + path: '/admin/config/content/ocfl-export' + defaults: + _form: '\Drupal\ocfl_export\Form\OcflSettingsForm' + _title: 'OCFL Export Settings' + requirements: + _permission: 'administer ocfl export' + +ocfl_export.export_node: + path: '/admin/content/ocfl-export/{node}' + defaults: + _controller: '\Drupal\ocfl_export\Controller\OcflExportController::exportNode' + _title: 'Export to OCFL' + requirements: + _permission: 'export ocfl content' + options: + parameters: + node: + type: entity:node + +ocfl_export.export_media: + path: '/admin/content/ocfl-export-media/{media}' + defaults: + _controller: '\Drupal\ocfl_export\Controller\OcflExportController::exportMedia' + _title: 'Export Media to OCFL' + requirements: + _permission: 'export ocfl content' + options: + parameters: + media: + type: entity:media + +ocfl_export.view_export: + path: '/admin/content/ocfl-view/{entity_type}/{uuid}' + defaults: + _controller: '\Drupal\ocfl_export\Controller\OcflExportController::viewExport' + _title: 'View OCFL Export' + requirements: + _permission: 'export ocfl content' + entity_type: 'node|media' diff --git a/ocfl_export.services.yml b/ocfl_export.services.yml new file mode 100644 index 0000000..cd5a36f --- /dev/null +++ b/ocfl_export.services.yml @@ -0,0 +1,19 @@ +services: + ocfl_export.storage: + class: Drupal\ocfl_export\OcflStorageService + arguments: + - '@config.factory' + - '@file_system' + - '@logger.factory' + + ocfl_export.node_serializer: + class: Drupal\ocfl_export\NodeSerializerService + arguments: + - '@entity_type.manager' + - '@datetime.time' + + ocfl_export.media_serializer: + class: Drupal\ocfl_export\MediaSerializerService + arguments: + - '@entity_type.manager' + - '@datetime.time' diff --git a/src/Controller/OcflExportController.php b/src/Controller/OcflExportController.php new file mode 100644 index 0000000..bc7046a --- /dev/null +++ b/src/Controller/OcflExportController.php @@ -0,0 +1,163 @@ +messenger = $messenger; + } + + /** + * {@inheritdoc} + */ + 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('messenger'), + ); + } + + /** + * Export a node to OCFL storage. + * + * @param \Drupal\node\NodeInterface $node + * The node to export. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * A redirect response back to the node. + */ + public function exportNode(NodeInterface $node): RedirectResponse { + try { + $data = $this->nodeSerializerService->serialize($node); + $success = $this->storageService->saveObject($node->uuid(), $data); + + if ($success) { + $this->messenger->addStatus($this->t('Node "@title" has been exported to OCFL storage.', [ + '@title' => $node->getTitle(), + ])); + + // Get object info for additional feedback. + $objectInfo = $this->storageService->getObjectInfo($node->uuid()); + if ($objectInfo) { + $this->messenger->addStatus($this->t('OCFL object version: @version', [ + '@version' => $objectInfo['head'] ?? 'unknown', + ])); + } + } + else { + $this->messenger->addError($this->t('Failed to export node "@title" to OCFL storage.', [ + '@title' => $node->getTitle(), + ])); + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('An error occurred while exporting: @message', [ + '@message' => $e->getMessage(), + ])); + $this->getLogger('ocfl_export')->error('Export failed for node @nid: @message', [ + '@nid' => $node->id(), + '@message' => $e->getMessage(), + ]); + } + + return new RedirectResponse(Url::fromRoute('entity.node.canonical', ['node' => $node->id()])->toString()); + } + + /** + * Export a media entity to OCFL storage. + * + * @param \Drupal\media\MediaInterface $media + * The media entity to export. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * A redirect response back to the media entity. + */ + public function exportMedia(MediaInterface $media): RedirectResponse { + try { + $data = $this->mediaSerializerService->serialize($media); + $success = $this->storageService->saveObject($media->uuid(), $data); + + if ($success) { + $this->messenger->addStatus($this->t('Media "@name" has been exported to OCFL storage.', [ + '@name' => $media->getName(), + ])); + + // Get object info for additional feedback. + $objectInfo = $this->storageService->getObjectInfo($media->uuid()); + if ($objectInfo) { + $this->messenger->addStatus($this->t('OCFL object version: @version', [ + '@version' => $objectInfo['head'] ?? 'unknown', + ])); + } + } + else { + $this->messenger->addError($this->t('Failed to export media "@name" to OCFL storage.', [ + '@name' => $media->getName(), + ])); + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('An error occurred while exporting: @message', [ + '@message' => $e->getMessage(), + ])); + $this->getLogger('ocfl_export')->error('Export failed for media @mid: @message', [ + '@mid' => $media->id(), + '@message' => $e->getMessage(), + ]); + } + + return new RedirectResponse(Url::fromRoute('entity.media.canonical', ['media' => $media->id()])->toString()); + } + + /** + * View the exported JSON file for an entity. + * + * @param string $entity_type + * The entity type ('node' or 'media'). + * @param string $uuid + * The entity UUID. + * + * @return \Symfony\Component\HttpFoundation\Response + * A response containing the JSON content. + */ + public function viewExport(string $entity_type, string $uuid): Response { + $filePath = $this->storageService->getLatestExportPath($uuid); + + if (!$filePath || !file_exists($filePath)) { + throw new NotFoundHttpException('OCFL export not found for this entity.'); + } + + $content = file_get_contents($filePath); + + return new Response($content, 200, [ + 'Content-Type' => 'application/json', + ]); + } + +} diff --git a/src/Form/OcflSettingsForm.php b/src/Form/OcflSettingsForm.php new file mode 100644 index 0000000..7a0656a --- /dev/null +++ b/src/Form/OcflSettingsForm.php @@ -0,0 +1,222 @@ +get('config.factory'), + $container->get('entity_type.manager'), + $container->get('ocfl_export.storage'), + ); + } + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames(): array { + return ['ocfl_export.settings']; + } + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'ocfl_export_settings_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $config = $this->config('ocfl_export.settings'); + + // Storage status. + $form['status'] = [ + '#type' => 'details', + '#title' => $this->t('Storage Status'), + '#open' => TRUE, + ]; + + $initialized = $this->storageService->isInitialized(); + $form['status']['storage_status'] = [ + '#type' => 'item', + '#title' => $this->t('OCFL Storage'), + '#markup' => $initialized + ? $this->t('Initialized at @path', ['@path' => $this->storageService->getStorageRoot()]) + : $this->t('Not initialized - will be created on first export'), + ]; + + // Storage settings. + $form['storage'] = [ + '#type' => 'details', + '#title' => $this->t('Storage Settings'), + '#open' => TRUE, + ]; + + $form['storage']['storage_root'] = [ + '#type' => 'textfield', + '#title' => $this->t('Storage Root Path'), + '#description' => $this->t('The filesystem path where OCFL objects will be stored. Use stream wrappers like private:// or public://, or an absolute path.'), + '#default_value' => $config->get('storage_root'), + '#required' => TRUE, + ]; + + $form['storage']['ocfl_version'] = [ + '#type' => 'select', + '#title' => $this->t('OCFL Version'), + '#options' => [ + '1.1' => '1.1', + '1.0' => '1.0', + ], + '#default_value' => $config->get('ocfl_version') ?: '1.1', + '#description' => $this->t('The OCFL specification version to use.'), + ]; + + // Export settings. + $form['export'] = [ + '#type' => 'details', + '#title' => $this->t('Export Settings'), + '#open' => TRUE, + ]; + + $form['export']['auto_export_enabled'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable automatic export on node save'), + '#description' => $this->t('When enabled, nodes will be automatically exported to OCFL when saved.'), + '#default_value' => $config->get('auto_export_enabled'), + ]; + + // Get all content types. + $contentTypes = $this->entityTypeManager->getStorage('node_type')->loadMultiple(); + $options = []; + foreach ($contentTypes as $type) { + $options[$type->id()] = $type->label(); + } + + $form['export']['content_types'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Content Types to Export'), + '#description' => $this->t('Select which content types should be exported. Leave empty to export all content types.'), + '#options' => $options, + '#default_value' => $config->get('content_types') ?: [], + ]; + + // Media export settings. + $form['media_export'] = [ + '#type' => 'details', + '#title' => $this->t('Media Export Settings'), + '#open' => TRUE, + ]; + + $form['media_export']['auto_export_media_enabled'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable automatic export on media save'), + '#description' => $this->t('When enabled, media entities will be automatically exported to OCFL when saved.'), + '#default_value' => $config->get('auto_export_media_enabled'), + ]; + + // Get all media types. + $mediaTypes = $this->entityTypeManager->getStorage('media_type')->loadMultiple(); + $mediaOptions = []; + foreach ($mediaTypes as $type) { + $mediaOptions[$type->id()] = $type->label(); + } + + $form['media_export']['media_types'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Media Types to Export'), + '#description' => $this->t('Select which media types should be exported. Leave empty to export all media types.'), + '#options' => $mediaOptions, + '#default_value' => $config->get('media_types') ?: [], + ]; + + // Initialize button. + $form['actions']['initialize'] = [ + '#type' => 'submit', + '#value' => $this->t('Initialize Storage'), + '#submit' => ['::initializeStorage'], + '#weight' => 10, + ]; + + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state): void { + parent::validateForm($form, $form_state); + + $storagePath = $form_state->getValue('storage_root'); + + // Check if path uses a valid stream wrapper or is an absolute path. + if (!str_contains($storagePath, '://') && !str_starts_with($storagePath, '/')) { + $form_state->setErrorByName('storage_root', $this->t('Storage path must be an absolute path or use a stream wrapper (e.g., private://).')); + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + // Filter out unchecked content types. + $contentTypes = array_filter($form_state->getValue('content_types')); + // Filter out unchecked media types. + $mediaTypes = array_filter($form_state->getValue('media_types')); + + $this->config('ocfl_export.settings') + ->set('storage_root', $form_state->getValue('storage_root')) + ->set('auto_export_enabled', (bool) $form_state->getValue('auto_export_enabled')) + ->set('content_types', array_values($contentTypes)) + ->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')) + ->save(); + + parent::submitForm($form, $form_state); + } + + /** + * Submit handler to initialize storage. + */ + public function initializeStorage(array &$form, FormStateInterface $form_state): void { + // Save config first. + $this->submitForm($form, $form_state); + + // Initialize storage. + if ($this->storageService->initializeStorageRoot()) { + $this->messenger()->addStatus($this->t('OCFL storage has been initialized at @path.', [ + '@path' => $this->storageService->getStorageRoot(), + ])); + } + else { + $this->messenger()->addError($this->t('Failed to initialize OCFL storage. Check the logs for details.')); + } + } + +} diff --git a/src/MediaSerializerService.php b/src/MediaSerializerService.php new file mode 100644 index 0000000..e02ebf1 --- /dev/null +++ b/src/MediaSerializerService.php @@ -0,0 +1,501 @@ +getOwner(); + + $data = [ + 'uuid' => $media->uuid(), + 'type' => $media->bundle(), + 'name' => $media->getName(), + 'created' => $this->formatTimestamp((int) $media->getCreatedTime()), + 'changed' => $this->formatTimestamp((int) $media->getChangedTime()), + 'status' => $media->isPublished(), + 'owner' => [ + 'uid' => (int) $owner->id(), + 'name' => $owner->getAccountName(), + 'display_name' => $owner->getDisplayName(), + ], + 'source' => $this->serializeMediaSource($media), + 'fields' => $this->serializeFields($media), + '_metadata' => [ + 'ocfl_version' => '1.1', + 'export_timestamp' => $this->formatTimestamp($this->time->getRequestTime()), + 'drupal_version' => \Drupal::VERSION, + 'entity_type' => 'media', + ], + ]; + + return $data; + } + + /** + * Serialize the media source (primary file/data). + * + * @param \Drupal\media\MediaInterface $media + * The media entity. + * + * @return array + * The serialized source data. + */ + protected function serializeMediaSource(MediaInterface $media): array { + $source = $media->getSource(); + $sourceConfig = $source->getConfiguration(); + $sourceField = $sourceConfig['source_field'] ?? NULL; + + $data = [ + 'plugin_id' => $source->getPluginId(), + ]; + + if ($sourceField && $media->hasField($sourceField)) { + $sourceFieldItems = $media->get($sourceField); + if (!$sourceFieldItems->isEmpty()) { + $sourceItem = $sourceFieldItems->first(); + $file = $sourceItem->entity ?? NULL; + + if ($file instanceof FileInterface) { + $data['file'] = [ + 'uuid' => $file->uuid(), + 'filename' => $file->getFilename(), + 'uri' => $file->getFileUri(), + 'filemime' => $file->getMimeType(), + 'filesize' => (int) $file->getSize(), + 'url' => \Drupal::service('file_url_generator')->generateAbsoluteString($file->getFileUri()), + ]; + + // Include alt and title if available (for images). + if (isset($sourceItem->alt)) { + $data['alt'] = $sourceItem->alt; + } + if (isset($sourceItem->title)) { + $data['title'] = $sourceItem->title; + } + if (isset($sourceItem->width)) { + $data['width'] = (int) $sourceItem->width; + } + if (isset($sourceItem->height)) { + $data['height'] = (int) $sourceItem->height; + } + } + else { + // For non-file sources (e.g., oEmbed, remote video). + $data['value'] = $sourceItem->getValue(); + } + } + } + + return $data; + } + + /** + * Serialize all fields of a media entity. + * + * @param \Drupal\media\MediaInterface $media + * The media entity to serialize fields from. + * + * @return array + * The serialized field data. + */ + protected function serializeFields(MediaInterface $media): array { + $fields = []; + $fieldDefinitions = $media->getFieldDefinitions(); + $sourceField = $media->getSource()->getConfiguration()['source_field'] ?? NULL; + + foreach ($fieldDefinitions as $fieldName => $definition) { + // Skip base fields and source field (already serialized). + if ($this->isBaseField($fieldName) || $fieldName === $sourceField) { + continue; + } + + $fieldItems = $media->get($fieldName); + if ($fieldItems->isEmpty()) { + continue; + } + + $fields[$fieldName] = $this->serializeFieldItems($fieldItems); + } + + return $fields; + } + + /** + * Check if a field is a base field that should be skipped. + * + * @param string $fieldName + * The field name. + * + * @return bool + * TRUE if this is a base field to skip. + */ + protected function isBaseField(string $fieldName): bool { + $baseFields = [ + 'mid', + 'vid', + 'uuid', + 'langcode', + 'bundle', + 'revision_created', + 'revision_user', + 'revision_log_message', + 'status', + 'uid', + 'name', + 'thumbnail', + 'created', + 'changed', + 'default_langcode', + 'revision_default', + 'revision_translation_affected', + ]; + + return in_array($fieldName, $baseFields, TRUE); + } + + /** + * Serialize field items. + * + * @param \Drupal\Core\Field\FieldItemListInterface $fieldItems + * The field items to serialize. + * + * @return mixed + * The serialized field data. + */ + protected function serializeFieldItems(FieldItemListInterface $fieldItems): mixed { + $fieldType = $fieldItems->getFieldDefinition()->getType(); + $values = []; + + foreach ($fieldItems as $delta => $item) { + $value = $this->serializeFieldItem($item, $fieldType); + if ($value !== NULL) { + $values[] = $value; + } + } + + // Return single value for single-value fields. + if (count($values) === 1 && $fieldItems->getFieldDefinition()->getFieldStorageDefinition()->getCardinality() === 1) { + return $values[0]; + } + + return $values; + } + + /** + * Serialize a single field item. + * + * @param mixed $item + * The field item. + * @param string $fieldType + * The field type. + * + * @return mixed + * The serialized value. + */ + protected function serializeFieldItem(mixed $item, string $fieldType): mixed { + return match ($fieldType) { + 'entity_reference' => $this->serializeEntityReference($item), + 'entity_reference_revisions' => $this->serializeEntityReferenceRevisions($item), + 'text_with_summary' => [ + 'value' => $item->value, + 'format' => $item->format, + 'summary' => $item->summary ?? '', + ], + 'text_long', 'text' => [ + 'value' => $item->value, + 'format' => $item->format ?? NULL, + ], + 'image' => [ + 'target_id' => (int) $item->target_id, + 'alt' => $item->alt ?? '', + 'title' => $item->title ?? '', + 'width' => $item->width ? (int) $item->width : NULL, + 'height' => $item->height ? (int) $item->height : NULL, + 'entity' => $this->serializeFileEntity($item->entity), + ], + 'file' => [ + 'target_id' => (int) $item->target_id, + 'description' => $item->description ?? '', + 'display' => $item->display ?? TRUE, + 'entity' => $this->serializeFileEntity($item->entity), + ], + 'link' => [ + 'uri' => $item->uri, + 'title' => $item->title ?? '', + 'options' => $item->options ?? [], + ], + 'datetime' => $item->value, + 'daterange' => [ + 'value' => $item->value, + 'end_value' => $item->end_value, + ], + 'boolean' => (bool) $item->value, + 'integer', 'list_integer' => (int) $item->value, + 'decimal', 'float', 'list_float' => (float) $item->value, + 'email' => $item->value, + 'telephone' => $item->value, + default => $item->getValue(), + }; + } + + /** + * Serialize an entity reference field item. + * + * @param mixed $item + * The field item. + * + * @return array|null + * The serialized reference or NULL. + */ + protected function serializeEntityReference(mixed $item): ?array { + $entity = $item->entity; + if (!$entity) { + return NULL; + } + + $data = [ + 'target_id' => (int) $item->target_id, + ]; + + // Dereference based on entity type. + $data['entity'] = match ($entity->getEntityTypeId()) { + 'taxonomy_term' => $this->serializeTaxonomyTerm($entity), + 'media' => $this->serializeMediaReference($entity), + 'user' => $this->serializeUser($entity), + 'node' => $this->serializeNodeReference($entity), + 'file' => $this->serializeFileEntity($entity), + default => [ + 'uuid' => $entity->uuid(), + 'type' => $entity->bundle(), + 'label' => $entity->label(), + ], + }; + + return $data; + } + + /** + * Serialize an entity reference revisions field item (paragraphs). + * + * @param mixed $item + * The field item. + * + * @return array|null + * The serialized reference or NULL. + */ + protected function serializeEntityReferenceRevisions(mixed $item): ?array { + $entity = $item->entity; + if (!$entity) { + return NULL; + } + + if ($entity instanceof ParagraphInterface) { + return $this->serializeParagraph($entity); + } + + return [ + 'target_id' => (int) $item->target_id, + 'target_revision_id' => (int) $item->target_revision_id, + 'entity' => [ + 'uuid' => $entity->uuid(), + 'type' => $entity->bundle(), + ], + ]; + } + + /** + * Serialize a taxonomy term. + * + * @param \Drupal\taxonomy\TermInterface $term + * The taxonomy term. + * + * @return array + * The serialized term data. + */ + protected function serializeTaxonomyTerm(TermInterface $term): array { + return [ + 'uuid' => $term->uuid(), + 'name' => $term->getName(), + 'description' => $term->getDescription(), + 'vocabulary' => $term->bundle(), + ]; + } + + /** + * Serialize a media reference (1 level only - no recursion). + * + * @param \Drupal\media\MediaInterface $media + * The referenced media entity. + * + * @return array + * The serialized media reference data. + */ + protected function serializeMediaReference(MediaInterface $media): array { + $data = [ + 'uuid' => $media->uuid(), + 'bundle' => $media->bundle(), + 'name' => $media->getName(), + 'status' => $media->isPublished(), + 'created' => $this->formatTimestamp((int) $media->getCreatedTime()), + 'changed' => $this->formatTimestamp((int) $media->getChangedTime()), + ]; + + // Get the source field value. + $sourceField = $media->getSource()->getConfiguration()['source_field'] ?? NULL; + if ($sourceField && $media->hasField($sourceField)) { + $sourceValue = $media->get($sourceField)->first(); + if ($sourceValue) { + $file = $sourceValue->entity ?? NULL; + if ($file instanceof FileInterface) { + $data['uri'] = $file->getFileUri(); + $data['url'] = \Drupal::service('file_url_generator')->generateAbsoluteString($file->getFileUri()); + } + if (isset($sourceValue->alt)) { + $data['alt'] = $sourceValue->alt; + } + } + } + + return $data; + } + + /** + * Serialize a user entity. + * + * @param \Drupal\user\UserInterface $user + * The user entity. + * + * @return array + * The serialized user data. + */ + protected function serializeUser(UserInterface $user): array { + return [ + 'uuid' => $user->uuid(), + 'uid' => (int) $user->id(), + 'name' => $user->getAccountName(), + 'display_name' => $user->getDisplayName(), + ]; + } + + /** + * Serialize a node reference (1 level only). + * + * @param \Drupal\node\NodeInterface $node + * The referenced node. + * + * @return array + * The serialized node reference data. + */ + protected function serializeNodeReference(NodeInterface $node): array { + return [ + 'uuid' => $node->uuid(), + 'type' => $node->bundle(), + 'title' => $node->getTitle(), + 'status' => $node->isPublished(), + 'created' => $this->formatTimestamp((int) $node->getCreatedTime()), + 'changed' => $this->formatTimestamp((int) $node->getChangedTime()), + ]; + } + + /** + * Serialize a paragraph entity. + * + * @param \Drupal\paragraphs\ParagraphInterface $paragraph + * The paragraph entity. + * + * @return array + * The serialized paragraph data. + */ + protected function serializeParagraph(ParagraphInterface $paragraph): array { + $data = [ + 'uuid' => $paragraph->uuid(), + 'type' => $paragraph->bundle(), + 'fields' => [], + ]; + + $fieldDefinitions = $paragraph->getFieldDefinitions(); + + foreach ($fieldDefinitions as $fieldName => $definition) { + // Skip base paragraph fields. + if (in_array($fieldName, ['id', 'uuid', 'revision_id', 'langcode', 'type', 'status', 'created', 'parent_id', 'parent_type', 'parent_field_name', 'behavior_settings', 'default_langcode', 'revision_default', 'revision_translation_affected'], TRUE)) { + continue; + } + + $fieldItems = $paragraph->get($fieldName); + if ($fieldItems->isEmpty()) { + continue; + } + + $data['fields'][$fieldName] = $this->serializeFieldItems($fieldItems); + } + + return $data; + } + + /** + * Serialize a file entity. + * + * @param mixed $file + * The file entity. + * + * @return array|null + * The serialized file data or NULL. + */ + protected function serializeFileEntity(mixed $file): ?array { + if (!$file) { + return NULL; + } + + return [ + 'uuid' => $file->uuid(), + 'filename' => $file->getFilename(), + 'uri' => $file->getFileUri(), + 'filemime' => $file->getMimeType(), + 'filesize' => (int) $file->getSize(), + ]; + } + + /** + * Format a timestamp as ISO 8601. + * + * @param int $timestamp + * The Unix timestamp. + * + * @return string + * The formatted date string. + */ + protected function formatTimestamp(int $timestamp): string { + return date('c', $timestamp); + } + +} diff --git a/src/NodeSerializerService.php b/src/NodeSerializerService.php new file mode 100644 index 0000000..e6c0189 --- /dev/null +++ b/src/NodeSerializerService.php @@ -0,0 +1,437 @@ +getOwner(); + + $data = [ + 'uuid' => $node->uuid(), + 'type' => $node->bundle(), + 'title' => $node->getTitle(), + 'created' => $this->formatTimestamp((int) $node->getCreatedTime()), + 'changed' => $this->formatTimestamp((int) $node->getChangedTime()), + 'status' => $node->isPublished(), + 'author' => [ + 'uid' => (int) $author->id(), + 'name' => $author->getAccountName(), + 'display_name' => $author->getDisplayName(), + ], + 'fields' => $this->serializeFields($node), + '_metadata' => [ + 'ocfl_version' => '1.1', + 'export_timestamp' => $this->formatTimestamp($this->time->getRequestTime()), + 'drupal_version' => \Drupal::VERSION, + ], + ]; + + return $data; + } + + /** + * Serialize all fields of a node. + * + * @param \Drupal\node\NodeInterface $node + * The node to serialize fields from. + * + * @return array + * The serialized field data. + */ + protected function serializeFields(NodeInterface $node): array { + $fields = []; + $fieldDefinitions = $node->getFieldDefinitions(); + + foreach ($fieldDefinitions as $fieldName => $definition) { + // Skip base fields that are already included in the main structure. + if ($this->isBaseField($fieldName)) { + continue; + } + + $fieldItems = $node->get($fieldName); + if ($fieldItems->isEmpty()) { + continue; + } + + $fields[$fieldName] = $this->serializeFieldItems($fieldItems); + } + + return $fields; + } + + /** + * Check if a field is a base field that should be skipped. + * + * @param string $fieldName + * The field name. + * + * @return bool + * TRUE if this is a base field to skip. + */ + protected function isBaseField(string $fieldName): bool { + $baseFields = [ + 'nid', + 'vid', + 'uuid', + 'langcode', + 'type', + 'revision_timestamp', + 'revision_uid', + 'revision_log', + 'status', + 'uid', + 'title', + 'created', + 'changed', + 'promote', + 'sticky', + 'default_langcode', + 'revision_default', + 'revision_translation_affected', + ]; + + return in_array($fieldName, $baseFields, TRUE); + } + + /** + * Serialize field items. + * + * @param \Drupal\Core\Field\FieldItemListInterface $fieldItems + * The field items to serialize. + * + * @return mixed + * The serialized field data. + */ + protected function serializeFieldItems(FieldItemListInterface $fieldItems): mixed { + $fieldType = $fieldItems->getFieldDefinition()->getType(); + $values = []; + + foreach ($fieldItems as $delta => $item) { + $value = $this->serializeFieldItem($item, $fieldType); + if ($value !== NULL) { + $values[] = $value; + } + } + + // Return single value for single-value fields. + if (count($values) === 1 && $fieldItems->getFieldDefinition()->getFieldStorageDefinition()->getCardinality() === 1) { + return $values[0]; + } + + return $values; + } + + /** + * Serialize a single field item. + * + * @param mixed $item + * The field item. + * @param string $fieldType + * The field type. + * + * @return mixed + * The serialized value. + */ + protected function serializeFieldItem(mixed $item, string $fieldType): mixed { + return match ($fieldType) { + 'entity_reference' => $this->serializeEntityReference($item), + 'entity_reference_revisions' => $this->serializeEntityReferenceRevisions($item), + 'text_with_summary' => [ + 'value' => $item->value, + 'format' => $item->format, + 'summary' => $item->summary ?? '', + ], + 'text_long', 'text' => [ + 'value' => $item->value, + 'format' => $item->format ?? NULL, + ], + 'image' => [ + 'target_id' => (int) $item->target_id, + 'alt' => $item->alt ?? '', + 'title' => $item->title ?? '', + 'width' => $item->width ? (int) $item->width : NULL, + 'height' => $item->height ? (int) $item->height : NULL, + 'entity' => $this->serializeFileEntity($item->entity), + ], + 'file' => [ + 'target_id' => (int) $item->target_id, + 'description' => $item->description ?? '', + 'display' => $item->display ?? TRUE, + 'entity' => $this->serializeFileEntity($item->entity), + ], + 'link' => [ + 'uri' => $item->uri, + 'title' => $item->title ?? '', + 'options' => $item->options ?? [], + ], + 'datetime' => $item->value, + 'daterange' => [ + 'value' => $item->value, + 'end_value' => $item->end_value, + ], + 'boolean' => (bool) $item->value, + 'integer', 'list_integer' => (int) $item->value, + 'decimal', 'float', 'list_float' => (float) $item->value, + 'email' => $item->value, + 'telephone' => $item->value, + default => $item->getValue(), + }; + } + + /** + * Serialize an entity reference field item. + * + * @param mixed $item + * The field item. + * + * @return array|null + * The serialized reference or NULL. + */ + protected function serializeEntityReference(mixed $item): ?array { + $entity = $item->entity; + if (!$entity) { + return NULL; + } + + $data = [ + 'target_id' => (int) $item->target_id, + ]; + + // Dereference based on entity type. + $data['entity'] = match ($entity->getEntityTypeId()) { + 'taxonomy_term' => $this->serializeTaxonomyTerm($entity), + 'media' => $this->serializeMedia($entity), + 'user' => $this->serializeUser($entity), + 'node' => $this->serializeNodeReference($entity), + default => [ + 'uuid' => $entity->uuid(), + 'type' => $entity->bundle(), + 'label' => $entity->label(), + ], + }; + + return $data; + } + + /** + * Serialize an entity reference revisions field item (paragraphs). + * + * @param mixed $item + * The field item. + * + * @return array|null + * The serialized reference or NULL. + */ + protected function serializeEntityReferenceRevisions(mixed $item): ?array { + $entity = $item->entity; + if (!$entity) { + return NULL; + } + + if ($entity instanceof ParagraphInterface) { + return $this->serializeParagraph($entity); + } + + return [ + 'target_id' => (int) $item->target_id, + 'target_revision_id' => (int) $item->target_revision_id, + 'entity' => [ + 'uuid' => $entity->uuid(), + 'type' => $entity->bundle(), + ], + ]; + } + + /** + * Serialize a taxonomy term. + * + * @param \Drupal\taxonomy\TermInterface $term + * The taxonomy term. + * + * @return array + * The serialized term data. + */ + protected function serializeTaxonomyTerm(TermInterface $term): array { + return [ + 'uuid' => $term->uuid(), + 'name' => $term->getName(), + 'description' => $term->getDescription(), + 'vocabulary' => $term->bundle(), + ]; + } + + /** + * Serialize a media entity. + * + * @param \Drupal\media\MediaInterface $media + * The media entity. + * + * @return array + * The serialized media data. + */ + protected function serializeMedia(MediaInterface $media): array { + $data = [ + 'uuid' => $media->uuid(), + 'name' => $media->getName(), + 'bundle' => $media->bundle(), + ]; + + // Get the source field value. + $sourceField = $media->getSource()->getConfiguration()['source_field'] ?? NULL; + if ($sourceField && $media->hasField($sourceField)) { + $sourceValue = $media->get($sourceField)->first(); + if ($sourceValue) { + $file = $sourceValue->entity ?? NULL; + if ($file) { + $data['uri'] = $file->getFileUri(); + $data['url'] = \Drupal::service('file_url_generator')->generateAbsoluteString($file->getFileUri()); + } + // Include alt text if available. + if (isset($sourceValue->alt)) { + $data['alt'] = $sourceValue->alt; + } + } + } + + return $data; + } + + /** + * Serialize a user entity. + * + * @param \Drupal\user\UserInterface $user + * The user entity. + * + * @return array + * The serialized user data. + */ + protected function serializeUser(UserInterface $user): array { + return [ + 'uuid' => $user->uuid(), + 'uid' => (int) $user->id(), + 'name' => $user->getAccountName(), + 'display_name' => $user->getDisplayName(), + ]; + } + + /** + * Serialize a node reference (1 level only). + * + * @param \Drupal\node\NodeInterface $node + * The referenced node. + * + * @return array + * The serialized node reference data. + */ + protected function serializeNodeReference(NodeInterface $node): array { + return [ + 'uuid' => $node->uuid(), + 'type' => $node->bundle(), + 'title' => $node->getTitle(), + 'status' => $node->isPublished(), + 'created' => $this->formatTimestamp((int) $node->getCreatedTime()), + 'changed' => $this->formatTimestamp((int) $node->getChangedTime()), + ]; + } + + /** + * Serialize a paragraph entity. + * + * @param \Drupal\paragraphs\ParagraphInterface $paragraph + * The paragraph entity. + * + * @return array + * The serialized paragraph data. + */ + protected function serializeParagraph(ParagraphInterface $paragraph): array { + $data = [ + 'uuid' => $paragraph->uuid(), + 'type' => $paragraph->bundle(), + 'fields' => [], + ]; + + $fieldDefinitions = $paragraph->getFieldDefinitions(); + + foreach ($fieldDefinitions as $fieldName => $definition) { + // Skip base paragraph fields. + if (in_array($fieldName, ['id', 'uuid', 'revision_id', 'langcode', 'type', 'status', 'created', 'parent_id', 'parent_type', 'parent_field_name', 'behavior_settings', 'default_langcode', 'revision_default', 'revision_translation_affected'], TRUE)) { + continue; + } + + $fieldItems = $paragraph->get($fieldName); + if ($fieldItems->isEmpty()) { + continue; + } + + $data['fields'][$fieldName] = $this->serializeFieldItems($fieldItems); + } + + return $data; + } + + /** + * Serialize a file entity. + * + * @param mixed $file + * The file entity. + * + * @return array|null + * The serialized file data or NULL. + */ + protected function serializeFileEntity(mixed $file): ?array { + if (!$file) { + return NULL; + } + + return [ + 'uuid' => $file->uuid(), + 'filename' => $file->getFilename(), + 'uri' => $file->getFileUri(), + 'filemime' => $file->getMimeType(), + 'filesize' => (int) $file->getSize(), + ]; + } + + /** + * Format a timestamp as ISO 8601. + * + * @param int $timestamp + * The Unix timestamp. + * + * @return string + * The formatted date string. + */ + protected function formatTimestamp(int $timestamp): string { + return date('c', $timestamp); + } + +} diff --git a/src/OcflStorageService.php b/src/OcflStorageService.php new file mode 100644 index 0000000..813a31a --- /dev/null +++ b/src/OcflStorageService.php @@ -0,0 +1,427 @@ +logger = $loggerFactory->get('ocfl_export'); + } + + /** + * Get the storage root path. + * + * @return string + * The absolute path to the storage root. + */ + public function getStorageRoot(): string { + $config = $this->configFactory->get('ocfl_export.settings'); + $path = $config->get('storage_root') ?: 'private://ocfl-storage'; + + return $this->fileSystem->realpath($path) ?: $path; + } + + /** + * Initialize the OCFL storage root. + * + * @return bool + * TRUE if initialization was successful. + */ + public function initializeStorageRoot(): bool { + $root = $this->getStorageRoot(); + + // Create directory if it doesn't exist. + if (!is_dir($root)) { + if (!$this->fileSystem->mkdir($root, 0755, TRUE)) { + $this->logger->error('Failed to create OCFL storage root: @path', ['@path' => $root]); + return FALSE; + } + } + + // Create OCFL root declaration file. + $declarationFile = $root . '/0=ocfl_1.1'; + if (!file_exists($declarationFile)) { + $content = "ocfl_1.1\nhttps://ocfl.io/1.1/spec/#root-conformance-declaration\n"; + if (file_put_contents($declarationFile, $content) === FALSE) { + $this->logger->error('Failed to create OCFL root declaration file.'); + return FALSE; + } + } + + // Create OCFL layout file. + $layoutFile = $root . '/ocfl_layout.json'; + if (!file_exists($layoutFile)) { + $layout = [ + 'extension' => '0002-flat-direct-storage-layout', + 'description' => 'OCFL storage using flat direct layout with UUIDs.', + ]; + if (file_put_contents($layoutFile, json_encode($layout, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === FALSE) { + $this->logger->error('Failed to create OCFL layout file.'); + return FALSE; + } + } + + return TRUE; + } + + /** + * Save or update an OCFL object. + * + * @param string $objectId + * The object ID (UUID). + * @param array $data + * The data to save. + * + * @return bool + * TRUE if save was successful. + */ + public function saveObject(string $objectId, array $data): bool { + if (!$this->initializeStorageRoot()) { + return FALSE; + } + + $root = $this->getStorageRoot(); + $objectPath = $root . '/' . $objectId; + + // Check if object exists and get current version. + $currentVersion = $this->getCurrentVersion($objectPath); + $newVersion = $currentVersion + 1; + $versionDir = 'v' . $newVersion; + + // Create object directory structure. + $contentPath = $objectPath . '/' . $versionDir . '/content'; + if (!is_dir($contentPath)) { + if (!$this->fileSystem->mkdir($contentPath, 0755, TRUE)) { + $this->logger->error('Failed to create OCFL object content directory: @path', ['@path' => $contentPath]); + return FALSE; + } + } + + // Create object declaration file for new objects. + if ($currentVersion === 0) { + $declarationFile = $objectPath . '/0=ocfl_object_1.1'; + $content = "ocfl_object_1.1\nhttps://ocfl.io/1.1/spec/#object-conformance-declaration\n"; + if (file_put_contents($declarationFile, $content) === FALSE) { + $this->logger->error('Failed to create OCFL object declaration file.'); + return FALSE; + } + } + + // Write node.json to content directory. + $nodeJsonPath = $contentPath . '/node.json'; + $jsonContent = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if (file_put_contents($nodeJsonPath, $jsonContent) === FALSE) { + $this->logger->error('Failed to write node.json to OCFL object.'); + return FALSE; + } + + // Generate and write inventory. + $inventory = $this->generateInventory($objectId, $objectPath, $newVersion, $nodeJsonPath); + + // Write inventory to version directory. + $versionInventoryPath = $objectPath . '/' . $versionDir . '/inventory.json'; + $inventoryJson = json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if (file_put_contents($versionInventoryPath, $inventoryJson) === FALSE) { + $this->logger->error('Failed to write version inventory.json.'); + return FALSE; + } + + // Write inventory checksum to version directory. + $versionChecksumPath = $objectPath . '/' . $versionDir . '/inventory.json.sha512'; + $checksum = hash('sha512', $inventoryJson); + if (file_put_contents($versionChecksumPath, $checksum . ' inventory.json') === FALSE) { + $this->logger->error('Failed to write version inventory checksum.'); + return FALSE; + } + + // Write inventory to object root. + $rootInventoryPath = $objectPath . '/inventory.json'; + if (file_put_contents($rootInventoryPath, $inventoryJson) === FALSE) { + $this->logger->error('Failed to write root inventory.json.'); + return FALSE; + } + + // Write inventory checksum to object root. + $rootChecksumPath = $objectPath . '/inventory.json.sha512'; + if (file_put_contents($rootChecksumPath, $checksum . ' inventory.json') === FALSE) { + $this->logger->error('Failed to write root inventory checksum.'); + return FALSE; + } + + $this->logger->info('OCFL object @id saved as version @version.', [ + '@id' => $objectId, + '@version' => $versionDir, + ]); + + return TRUE; + } + + /** + * Mark an OCFL object as deleted (for nodes). + * + * @param \Drupal\node\NodeInterface $node + * The node being deleted. + * + * @return bool + * TRUE if marking was successful. + */ + public function markDeleted(\Drupal\node\NodeInterface $node): bool { + $root = $this->getStorageRoot(); + $objectPath = $root . '/' . $node->uuid(); + + if (!is_dir($objectPath)) { + return TRUE; + } + + // Create a new version with deletion marker. + $data = [ + 'uuid' => $node->uuid(), + 'type' => $node->bundle(), + 'title' => $node->getTitle(), + 'deleted' => TRUE, + 'deleted_timestamp' => date('c'), + '_metadata' => [ + 'ocfl_version' => '1.1', + 'export_timestamp' => date('c'), + 'drupal_version' => \Drupal::VERSION, + 'entity_type' => 'node', + 'deletion_marker' => TRUE, + ], + ]; + + return $this->saveObject($node->uuid(), $data); + } + + /** + * Mark an OCFL object as deleted (for media). + * + * @param \Drupal\media\MediaInterface $media + * The media entity being deleted. + * + * @return bool + * TRUE if marking was successful. + */ + public function markMediaDeleted(\Drupal\media\MediaInterface $media): bool { + $root = $this->getStorageRoot(); + $objectPath = $root . '/' . $media->uuid(); + + if (!is_dir($objectPath)) { + return TRUE; + } + + // Create a new version with deletion marker. + $data = [ + 'uuid' => $media->uuid(), + 'type' => $media->bundle(), + 'name' => $media->getName(), + 'deleted' => TRUE, + 'deleted_timestamp' => date('c'), + '_metadata' => [ + 'ocfl_version' => '1.1', + 'export_timestamp' => date('c'), + 'drupal_version' => \Drupal::VERSION, + 'entity_type' => 'media', + 'deletion_marker' => TRUE, + ], + ]; + + return $this->saveObject($media->uuid(), $data); + } + + /** + * Get the current version number for an object. + * + * @param string $objectPath + * The path to the object directory. + * + * @return int + * The current version number (0 if object doesn't exist). + */ + protected function getCurrentVersion(string $objectPath): int { + if (!is_dir($objectPath)) { + return 0; + } + + $inventoryPath = $objectPath . '/inventory.json'; + if (!file_exists($inventoryPath)) { + return 0; + } + + $inventory = json_decode(file_get_contents($inventoryPath), TRUE); + if (!$inventory || !isset($inventory['head'])) { + return 0; + } + + // Parse version number from head (e.g., "v3" -> 3). + return (int) substr($inventory['head'], 1); + } + + /** + * Generate OCFL inventory for an object. + * + * @param string $objectId + * The object ID. + * @param string $objectPath + * The object path. + * @param int $version + * The new version number. + * @param string $nodeJsonPath + * The path to the new node.json file. + * + * @return array + * The inventory data. + */ + protected function generateInventory(string $objectId, string $objectPath, int $version, string $nodeJsonPath): array { + $versionDir = 'v' . $version; + + // Calculate checksum for the new content file. + $contentChecksum = hash_file('sha512', $nodeJsonPath); + + // Load existing inventory if this is an update. + $existingManifest = []; + $existingVersions = []; + if ($version > 1) { + $existingInventoryPath = $objectPath . '/inventory.json'; + if (file_exists($existingInventoryPath)) { + $existing = json_decode(file_get_contents($existingInventoryPath), TRUE); + $existingManifest = $existing['manifest'] ?? []; + $existingVersions = $existing['versions'] ?? []; + } + } + + // Build manifest - maps checksums to file paths. + $manifest = $existingManifest; + $contentPath = $versionDir . '/content/node.json'; + $manifest[$contentChecksum] = [$contentPath]; + + // Build version state - maps checksums to logical paths. + $versionState = [ + $contentChecksum => ['node.json'], + ]; + + // Build versions array. + $versions = $existingVersions; + $versions[$versionDir] = [ + 'created' => date('c'), + 'message' => $version === 1 ? 'Initial version' : 'Updated version', + 'state' => $versionState, + ]; + + $inventory = [ + 'id' => $objectId, + 'type' => 'https://ocfl.io/1.1/spec/#inventory', + 'digestAlgorithm' => 'sha512', + 'head' => $versionDir, + 'manifest' => $manifest, + 'versions' => $versions, + ]; + + return $inventory; + } + + /** + * Check if storage is properly initialized. + * + * @return bool + * TRUE if storage is initialized. + */ + public function isInitialized(): bool { + $root = $this->getStorageRoot(); + return file_exists($root . '/0=ocfl_1.1') && file_exists($root . '/ocfl_layout.json'); + } + + /** + * Get object info by UUID. + * + * @param string $uuid + * The object UUID. + * + * @return array|null + * Object info or NULL if not found. + */ + public function getObjectInfo(string $uuid): ?array { + $root = $this->getStorageRoot(); + $objectPath = $root . '/' . $uuid; + $inventoryPath = $objectPath . '/inventory.json'; + + if (!file_exists($inventoryPath)) { + return NULL; + } + + return json_decode(file_get_contents($inventoryPath), TRUE); + } + + /** + * Get the path to the latest export file for an object. + * + * @param string $uuid + * The object UUID. + * + * @return string|null + * The absolute path to the latest node.json file, or NULL if not found. + */ + public function getLatestExportPath(string $uuid): ?string { + $objectInfo = $this->getObjectInfo($uuid); + if (!$objectInfo || !isset($objectInfo['head'])) { + return NULL; + } + + $root = $this->getStorageRoot(); + $filePath = $root . '/' . $uuid . '/' . $objectInfo['head'] . '/content/node.json'; + + if (!file_exists($filePath)) { + return NULL; + } + + return $filePath; + } + + /** + * Get export status info for an entity. + * + * @param string $uuid + * The entity UUID. + * + * @return array|null + * Array with 'version', 'path', and 'created' keys, or NULL if not exported. + */ + public function getExportStatus(string $uuid): ?array { + $objectInfo = $this->getObjectInfo($uuid); + if (!$objectInfo || !isset($objectInfo['head'])) { + return NULL; + } + + $head = $objectInfo['head']; + $root = $this->getStorageRoot(); + $filePath = $root . '/' . $uuid . '/' . $head . '/content/node.json'; + + if (!file_exists($filePath)) { + return NULL; + } + + $created = $objectInfo['versions'][$head]['created'] ?? NULL; + + return [ + 'version' => $head, + 'path' => $filePath, + 'created' => $created, + ]; + } + +} diff --git a/src/Plugin/Action/ExportMediaToOcfl.php b/src/Plugin/Action/ExportMediaToOcfl.php new file mode 100644 index 0000000..08f0d86 --- /dev/null +++ b/src/Plugin/Action/ExportMediaToOcfl.php @@ -0,0 +1,96 @@ +get('ocfl_export.storage'), + $container->get('ocfl_export.media_serializer'), + $container->get('logger.factory')->get('ocfl_export'), + ); + } + + /** + * {@inheritdoc} + */ + public function execute(?MediaInterface $media = NULL): void { + if (!$media) { + return; + } + + try { + $data = $this->serializerService->serialize($media); + $success = $this->storageService->saveObject($media->uuid(), $data); + + if ($success) { + $this->logger->info('Batch exported media @mid (@name) to OCFL.', [ + '@mid' => $media->id(), + '@name' => $media->getName(), + ]); + } + else { + $this->logger->error('Failed to batch export media @mid to OCFL.', [ + '@mid' => $media->id(), + ]); + } + } + catch (\Exception $e) { + $this->logger->error('Error exporting media @mid to OCFL: @message', [ + '@mid' => $media->id(), + '@message' => $e->getMessage(), + ]); + } + } + + /** + * {@inheritdoc} + */ + public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE): bool { + /** @var \Drupal\media\MediaInterface $object */ + $access = $object->access('view', $account, TRUE) + ->andIf($object->access('update', $account, TRUE)); + + return $return_as_object ? $access : $access->isAllowed(); + } + +} diff --git a/src/Plugin/Action/ExportNodeToOcfl.php b/src/Plugin/Action/ExportNodeToOcfl.php new file mode 100644 index 0000000..aeb9ae5 --- /dev/null +++ b/src/Plugin/Action/ExportNodeToOcfl.php @@ -0,0 +1,96 @@ +get('ocfl_export.storage'), + $container->get('ocfl_export.node_serializer'), + $container->get('logger.factory')->get('ocfl_export'), + ); + } + + /** + * {@inheritdoc} + */ + public function execute(?NodeInterface $node = NULL): void { + if (!$node) { + return; + } + + try { + $data = $this->serializerService->serialize($node); + $success = $this->storageService->saveObject($node->uuid(), $data); + + if ($success) { + $this->logger->info('Batch exported node @nid (@title) to OCFL.', [ + '@nid' => $node->id(), + '@title' => $node->getTitle(), + ]); + } + else { + $this->logger->error('Failed to batch export node @nid to OCFL.', [ + '@nid' => $node->id(), + ]); + } + } + catch (\Exception $e) { + $this->logger->error('Error exporting node @nid to OCFL: @message', [ + '@nid' => $node->id(), + '@message' => $e->getMessage(), + ]); + } + } + + /** + * {@inheritdoc} + */ + public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE): bool { + /** @var \Drupal\node\NodeInterface $object */ + $access = $object->access('view', $account, TRUE) + ->andIf($object->access('update', $account, TRUE)); + + return $return_as_object ? $access : $access->isAllowed(); + } + +}