Browse Source

first commit

main
Paul Pound 1 week ago
commit
a991225c04
  1. 6
      config/install/ocfl_export.settings.yml
  2. 11
      config/install/system.action.ocfl_export_media.yml
  3. 11
      config/install/system.action.ocfl_export_node.yml
  4. 28
      config/schema/ocfl_export.schema.yml
  5. 11
      ocfl_export.info.yml
  6. 13
      ocfl_export.links.action.yml
  7. 264
      ocfl_export.module
  8. 8
      ocfl_export.permissions.yml
  9. 40
      ocfl_export.routing.yml
  10. 19
      ocfl_export.services.yml
  11. 163
      src/Controller/OcflExportController.php
  12. 222
      src/Form/OcflSettingsForm.php
  13. 501
      src/MediaSerializerService.php
  14. 437
      src/NodeSerializerService.php
  15. 427
      src/OcflStorageService.php
  16. 96
      src/Plugin/Action/ExportMediaToOcfl.php
  17. 96
      src/Plugin/Action/ExportNodeToOcfl.php

6
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'

11
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: {}

11
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: {}

28
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'

11
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

13
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

264
ocfl_export.module

@ -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;
}

8
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.'

40
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'

19
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'

163
src/Controller/OcflExportController.php

@ -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',
]);
}
}

222
src/Form/OcflSettingsForm.php

@ -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.'));
}
}
}

501
src/MediaSerializerService.php

@ -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);
}
}

437
src/NodeSerializerService.php

@ -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);
}
}

427
src/OcflStorageService.php

@ -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,
];
}
}

96
src/Plugin/Action/ExportMediaToOcfl.php

@ -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();
}
}

96
src/Plugin/Action/ExportNodeToOcfl.php

@ -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…
Cancel
Save