commit
a991225c04
17 changed files with 2353 additions and 0 deletions
@ -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' |
||||
@ -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: {} |
||||
@ -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: {} |
||||
@ -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' |
||||
@ -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 |
||||
@ -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 |
||||
@ -0,0 +1,264 @@
|
||||
<?php |
||||
|
||||
/** |
||||
* @file |
||||
* OCFL Export module hooks and functions. |
||||
*/ |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
use Drupal\Core\Entity\Display\EntityViewDisplayInterface; |
||||
use Drupal\Core\Entity\EntityInterface; |
||||
use Drupal\Core\Url; |
||||
use Drupal\media\MediaInterface; |
||||
use Drupal\node\NodeInterface; |
||||
|
||||
/** |
||||
* Implements hook_node_insert(). |
||||
*/ |
||||
function ocfl_export_node_insert(NodeInterface $node): void { |
||||
_ocfl_export_save_node($node); |
||||
} |
||||
|
||||
/** |
||||
* Implements hook_node_update(). |
||||
*/ |
||||
function ocfl_export_node_update(NodeInterface $node): void { |
||||
_ocfl_export_save_node($node); |
||||
} |
||||
|
||||
/** |
||||
* Implements hook_node_delete(). |
||||
*/ |
||||
function ocfl_export_node_delete(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\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; |
||||
} |
||||
@ -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.' |
||||
@ -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' |
||||
@ -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' |
||||
@ -0,0 +1,163 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Drupal\ocfl_export\Controller; |
||||
|
||||
use Drupal\Core\Controller\ControllerBase; |
||||
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\OcflStorageService; |
||||
use Symfony\Component\DependencyInjection\ContainerInterface; |
||||
use Symfony\Component\HttpFoundation\RedirectResponse; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; |
||||
|
||||
/** |
||||
* Controller for manual OCFL export operations. |
||||
*/ |
||||
class OcflExportController extends ControllerBase { |
||||
|
||||
public function __construct( |
||||
protected OcflStorageService $storageService, |
||||
protected NodeSerializerService $nodeSerializerService, |
||||
protected MediaSerializerService $mediaSerializerService, |
||||
MessengerInterface $messenger, |
||||
) { |
||||
$this->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', |
||||
]); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,222 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Drupal\ocfl_export\Form; |
||||
|
||||
use Drupal\Core\Config\ConfigFactoryInterface; |
||||
use Drupal\Core\Entity\EntityTypeManagerInterface; |
||||
use Drupal\Core\Form\ConfigFormBase; |
||||
use Drupal\Core\Form\FormStateInterface; |
||||
use Drupal\ocfl_export\OcflStorageService; |
||||
use Symfony\Component\DependencyInjection\ContainerInterface; |
||||
|
||||
/** |
||||
* Configuration form for OCFL Export settings. |
||||
*/ |
||||
class OcflSettingsForm extends ConfigFormBase { |
||||
|
||||
public function __construct( |
||||
ConfigFactoryInterface $config_factory, |
||||
protected EntityTypeManagerInterface $entityTypeManager, |
||||
protected OcflStorageService $storageService, |
||||
) { |
||||
parent::__construct($config_factory); |
||||
} |
||||
|
||||
/** |
||||
* {@inheritdoc} |
||||
*/ |
||||
public static function create(ContainerInterface $container): static { |
||||
return new static( |
||||
$container->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('<span style="color: green;">Initialized</span> at @path', ['@path' => $this->storageService->getStorageRoot()]) |
||||
: $this->t('<span style="color: orange;">Not initialized</span> - 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.')); |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,501 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Drupal\ocfl_export; |
||||
|
||||
use Drupal\Component\Datetime\TimeInterface; |
||||
use Drupal\Core\Entity\EntityTypeManagerInterface; |
||||
use Drupal\Core\Field\FieldItemListInterface; |
||||
use Drupal\file\FileInterface; |
||||
use Drupal\media\MediaInterface; |
||||
use Drupal\node\NodeInterface; |
||||
use Drupal\paragraphs\ParagraphInterface; |
||||
use Drupal\taxonomy\TermInterface; |
||||
use Drupal\user\UserInterface; |
||||
|
||||
/** |
||||
* Service for serializing media entities to JSON format. |
||||
*/ |
||||
class MediaSerializerService { |
||||
|
||||
public function __construct( |
||||
protected EntityTypeManagerInterface $entityTypeManager, |
||||
protected TimeInterface $time, |
||||
) {} |
||||
|
||||
/** |
||||
* Serialize a media entity to an array suitable for JSON encoding. |
||||
* |
||||
* @param \Drupal\media\MediaInterface $media |
||||
* The media entity to serialize. |
||||
* |
||||
* @return array |
||||
* The serialized media data. |
||||
*/ |
||||
public function serialize(MediaInterface $media): array { |
||||
$owner = $media->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); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,437 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Drupal\ocfl_export; |
||||
|
||||
use Drupal\Component\Datetime\TimeInterface; |
||||
use Drupal\Core\Entity\EntityTypeManagerInterface; |
||||
use Drupal\Core\Field\FieldItemListInterface; |
||||
use Drupal\media\MediaInterface; |
||||
use Drupal\node\NodeInterface; |
||||
use Drupal\paragraphs\ParagraphInterface; |
||||
use Drupal\taxonomy\TermInterface; |
||||
use Drupal\user\UserInterface; |
||||
|
||||
/** |
||||
* Service for serializing nodes to JSON format. |
||||
*/ |
||||
class NodeSerializerService { |
||||
|
||||
public function __construct( |
||||
protected EntityTypeManagerInterface $entityTypeManager, |
||||
protected TimeInterface $time, |
||||
) {} |
||||
|
||||
/** |
||||
* Serialize a node to an array suitable for JSON encoding. |
||||
* |
||||
* @param \Drupal\node\NodeInterface $node |
||||
* The node to serialize. |
||||
* |
||||
* @return array |
||||
* The serialized node data. |
||||
*/ |
||||
public function serialize(NodeInterface $node): array { |
||||
$author = $node->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); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,427 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Drupal\ocfl_export; |
||||
|
||||
use Drupal\Core\Config\ConfigFactoryInterface; |
||||
use Drupal\Core\File\FileSystemInterface; |
||||
use Drupal\Core\Logger\LoggerChannelFactoryInterface; |
||||
use Psr\Log\LoggerInterface; |
||||
|
||||
/** |
||||
* Service for managing OCFL storage operations. |
||||
*/ |
||||
class OcflStorageService { |
||||
|
||||
protected LoggerInterface $logger; |
||||
|
||||
public function __construct( |
||||
protected ConfigFactoryInterface $configFactory, |
||||
protected FileSystemInterface $fileSystem, |
||||
LoggerChannelFactoryInterface $loggerFactory, |
||||
) { |
||||
$this->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, |
||||
]; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,96 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Drupal\ocfl_export\Plugin\Action; |
||||
|
||||
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 Psr\Log\LoggerInterface; |
||||
use Symfony\Component\DependencyInjection\ContainerInterface; |
||||
|
||||
/** |
||||
* Exports a media entity to OCFL storage. |
||||
* |
||||
* @Action( |
||||
* id = "ocfl_export_media", |
||||
* label = @Translation("Export to OCFL"), |
||||
* type = "media", |
||||
* confirm = TRUE, |
||||
* ) |
||||
*/ |
||||
class ExportMediaToOcfl extends ActionBase implements ContainerFactoryPluginInterface { |
||||
|
||||
public function __construct( |
||||
array $configuration, |
||||
$plugin_id, |
||||
$plugin_definition, |
||||
protected OcflStorageService $storageService, |
||||
protected MediaSerializerService $serializerService, |
||||
protected LoggerInterface $logger, |
||||
) { |
||||
parent::__construct($configuration, $plugin_id, $plugin_definition); |
||||
} |
||||
|
||||
/** |
||||
* {@inheritdoc} |
||||
*/ |
||||
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { |
||||
return new static( |
||||
$configuration, |
||||
$plugin_id, |
||||
$plugin_definition, |
||||
$container->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(); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,96 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Drupal\ocfl_export\Plugin\Action; |
||||
|
||||
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 Psr\Log\LoggerInterface; |
||||
use Symfony\Component\DependencyInjection\ContainerInterface; |
||||
|
||||
/** |
||||
* Exports a node to OCFL storage. |
||||
* |
||||
* @Action( |
||||
* id = "ocfl_export_node", |
||||
* label = @Translation("Export to OCFL"), |
||||
* type = "node", |
||||
* confirm = TRUE, |
||||
* ) |
||||
*/ |
||||
class ExportNodeToOcfl extends ActionBase implements ContainerFactoryPluginInterface { |
||||
|
||||
public function __construct( |
||||
array $configuration, |
||||
$plugin_id, |
||||
$plugin_definition, |
||||
protected OcflStorageService $storageService, |
||||
protected NodeSerializerService $serializerService, |
||||
protected LoggerInterface $logger, |
||||
) { |
||||
parent::__construct($configuration, $plugin_id, $plugin_definition); |
||||
} |
||||
|
||||
/** |
||||
* {@inheritdoc} |
||||
*/ |
||||
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { |
||||
return new static( |
||||
$configuration, |
||||
$plugin_id, |
||||
$plugin_definition, |
||||
$container->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(); |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue