Browse Source
Add an OcflExporter service that builds the complete object for a node:
node.json plus the binaries of
- media pointing at the node through the "media of" field (Islandora:
field_media_of), stored under media/{media uuid}/,
- media referenced from the node's own media reference fields, and
- files in the node's own file/image fields, under files/{field}/.
node.json gains a media list (each media's metadata, how it is
associated with the node and the logical paths of its files) and a files
list. Standalone media objects include their own files too.
Automatic exports are queued and run once per node when the request ends
(needs_destruction), so saving a node together with several media creates
a single version. Media inserts, updates and deletes queue the media's
parent nodes, before and after the change, so derivatives and removed
media are reflected in the node's object. A successful export invalidates
the entity's cache tags so the export status shown on its page is current.
The controller, bulk actions and hooks now go through the exporter.
New settings, with an update hook to add them to existing sites:
include_files, media_of_field, member_of_field and model_field (the
latter two are used by the node serializer for membership data).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ocfl-files-and-hierarchy
10 changed files with 520 additions and 86 deletions
@ -0,0 +1,27 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
/** |
||||||
|
* @file |
||||||
|
* Install, update and uninstall functions for the OCFL Export module. |
||||||
|
*/ |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
/** |
||||||
|
* Add settings for file export and Islandora relationship fields. |
||||||
|
*/ |
||||||
|
function ocfl_export_update_10001(): void { |
||||||
|
$config = \Drupal::configFactory()->getEditable('ocfl_export.settings'); |
||||||
|
$defaults = [ |
||||||
|
'include_files' => TRUE, |
||||||
|
'member_of_field' => 'field_member_of', |
||||||
|
'media_of_field' => 'field_media_of', |
||||||
|
'model_field' => 'field_model', |
||||||
|
]; |
||||||
|
foreach ($defaults as $key => $value) { |
||||||
|
if ($config->get($key) === NULL) { |
||||||
|
$config->set($key, $value); |
||||||
|
} |
||||||
|
} |
||||||
|
$config->save(); |
||||||
|
} |
||||||
@ -0,0 +1,390 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace Drupal\ocfl_export; |
||||||
|
|
||||||
|
use Drupal\Core\Cache\Cache; |
||||||
|
use Drupal\Core\Config\ConfigFactoryInterface; |
||||||
|
use Drupal\Core\DestructableInterface; |
||||||
|
use Drupal\Core\Entity\EntityFieldManagerInterface; |
||||||
|
use Drupal\Core\Entity\EntityInterface; |
||||||
|
use Drupal\Core\Entity\EntityTypeManagerInterface; |
||||||
|
use Drupal\Core\Entity\FieldableEntityInterface; |
||||||
|
use Drupal\Core\Logger\LoggerChannelFactoryInterface; |
||||||
|
use Drupal\file\FileInterface; |
||||||
|
use Drupal\media\MediaInterface; |
||||||
|
use Drupal\node\NodeInterface; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
/** |
||||||
|
* Builds complete OCFL objects (metadata plus files) for nodes and media. |
||||||
|
* |
||||||
|
* A node object contains node.json plus the binaries of: |
||||||
|
* - media that point at the node through the "media of" field (Islandora), |
||||||
|
* stored under media/{media uuid}/; |
||||||
|
* - media referenced from the node's own media reference fields, stored the |
||||||
|
* same way; |
||||||
|
* - files in the node's own file and image fields, stored under |
||||||
|
* files/{field name}/. |
||||||
|
* |
||||||
|
* Automatic (hook driven) exports are queued and run once per node when the |
||||||
|
* request ends, so saving a node together with several media creates a single |
||||||
|
* new version rather than one per save. |
||||||
|
*/ |
||||||
|
class OcflExporter implements DestructableInterface { |
||||||
|
|
||||||
|
/** |
||||||
|
* File field types whose files are copied into objects. |
||||||
|
*/ |
||||||
|
protected const FILE_FIELD_TYPES = ['file', 'image']; |
||||||
|
|
||||||
|
/** |
||||||
|
* The logger channel. |
||||||
|
*/ |
||||||
|
protected LoggerInterface $logger; |
||||||
|
|
||||||
|
/** |
||||||
|
* Node IDs queued for export at the end of the request. |
||||||
|
* |
||||||
|
* @var int[] |
||||||
|
*/ |
||||||
|
protected array $queue = []; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
protected EntityTypeManagerInterface $entityTypeManager, |
||||||
|
protected EntityFieldManagerInterface $entityFieldManager, |
||||||
|
protected ConfigFactoryInterface $configFactory, |
||||||
|
protected OcflStorageService $storage, |
||||||
|
protected NodeSerializerService $nodeSerializer, |
||||||
|
protected MediaSerializerService $mediaSerializer, |
||||||
|
LoggerChannelFactoryInterface $loggerFactory, |
||||||
|
) { |
||||||
|
$this->logger = $loggerFactory->get('ocfl_export'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Export a node, its associated media and its files as one OCFL object. |
||||||
|
* |
||||||
|
* @param \Drupal\node\NodeInterface $node |
||||||
|
* The node. |
||||||
|
* @param string|null $message |
||||||
|
* Optional OCFL version message. |
||||||
|
* |
||||||
|
* @return bool |
||||||
|
* TRUE on success. |
||||||
|
*/ |
||||||
|
public function exportNode(NodeInterface $node, ?string $message = NULL): bool { |
||||||
|
$data = $this->nodeSerializer->serialize($node); |
||||||
|
$files = []; |
||||||
|
|
||||||
|
$data['files'] = $this->collectEntityFiles($node, 'files', TRUE, $files); |
||||||
|
|
||||||
|
$data['media'] = []; |
||||||
|
foreach ($this->getAssociatedMedia($node) as $item) { |
||||||
|
/** @var \Drupal\media\MediaInterface $media */ |
||||||
|
$media = $item['media']; |
||||||
|
$mediaData = $this->mediaSerializer->serialize($media); |
||||||
|
unset($mediaData['_metadata']); |
||||||
|
$mediaData['association'] = $item['association']; |
||||||
|
$mediaData['files'] = $this->collectEntityFiles($media, 'media/' . $media->uuid(), FALSE, $files, ['thumbnail']); |
||||||
|
$data['media'][] = $mediaData; |
||||||
|
} |
||||||
|
|
||||||
|
if (!$this->includeFiles()) { |
||||||
|
$files = []; |
||||||
|
} |
||||||
|
|
||||||
|
return $this->save($node, $data, $files, $message); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Export a media entity and its files as its own OCFL object. |
||||||
|
* |
||||||
|
* @param \Drupal\media\MediaInterface $media |
||||||
|
* The media entity. |
||||||
|
* @param string|null $message |
||||||
|
* Optional OCFL version message. |
||||||
|
* |
||||||
|
* @return bool |
||||||
|
* TRUE on success. |
||||||
|
*/ |
||||||
|
public function exportMedia(MediaInterface $media, ?string $message = NULL): bool { |
||||||
|
$data = $this->mediaSerializer->serialize($media); |
||||||
|
$files = []; |
||||||
|
$data['files'] = $this->collectEntityFiles($media, 'files', TRUE, $files, ['thumbnail']); |
||||||
|
|
||||||
|
if (!$this->includeFiles()) { |
||||||
|
$files = []; |
||||||
|
} |
||||||
|
|
||||||
|
return $this->save($media, $data, $files, $message); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Save an entity's OCFL object and refresh its rendered export status. |
||||||
|
* |
||||||
|
* @param \Drupal\Core\Entity\EntityInterface $entity |
||||||
|
* The exported entity. |
||||||
|
* @param array $data |
||||||
|
* The metadata. |
||||||
|
* @param array $files |
||||||
|
* Map of logical path => file URI. |
||||||
|
* @param string|null $message |
||||||
|
* Optional OCFL version message. |
||||||
|
* |
||||||
|
* @return bool |
||||||
|
* TRUE on success. |
||||||
|
*/ |
||||||
|
protected function save(EntityInterface $entity, array $data, array $files, ?string $message): bool { |
||||||
|
$saved = $this->storage->saveObject($entity->uuid(), $data, $files, $message); |
||||||
|
if ($saved) { |
||||||
|
// The export status block is part of the cached entity render. |
||||||
|
Cache::invalidateTags($entity->getCacheTagsToInvalidate()); |
||||||
|
} |
||||||
|
return $saved; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Queue a node for automatic export if the settings allow it. |
||||||
|
* |
||||||
|
* @param \Drupal\node\NodeInterface $node |
||||||
|
* The node. |
||||||
|
*/ |
||||||
|
public function queueNode(NodeInterface $node): void { |
||||||
|
if ($this->shouldAutoExportNode($node)) { |
||||||
|
$this->queue[(int) $node->id()] = (int) $node->id(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Queue the nodes a media entity belongs to (before and after the change). |
||||||
|
* |
||||||
|
* @param \Drupal\media\MediaInterface $media |
||||||
|
* The media entity being inserted, updated or deleted. |
||||||
|
*/ |
||||||
|
public function queueParentNodes(MediaInterface $media): void { |
||||||
|
$field = $this->configFactory->get('ocfl_export.settings')->get('media_of_field'); |
||||||
|
if (!$field || !$media->hasField($field)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$parents = $media->get($field)->referencedEntities(); |
||||||
|
$original = method_exists($media, 'getOriginal') ? $media->getOriginal() : ($media->original ?? NULL); |
||||||
|
if ($original instanceof MediaInterface && $original->hasField($field)) { |
||||||
|
$parents = array_merge($parents, $original->get($field)->referencedEntities()); |
||||||
|
} |
||||||
|
|
||||||
|
foreach ($parents as $parent) { |
||||||
|
if ($parent instanceof NodeInterface) { |
||||||
|
$this->queueNode($parent); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Remove a node from the export queue (e.g. because it was deleted). |
||||||
|
* |
||||||
|
* @param \Drupal\node\NodeInterface $node |
||||||
|
* The node. |
||||||
|
*/ |
||||||
|
public function unqueueNode(NodeInterface $node): void { |
||||||
|
unset($this->queue[(int) $node->id()]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* {@inheritdoc} |
||||||
|
*/ |
||||||
|
public function destruct(): void { |
||||||
|
if (!$this->queue) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$nids = $this->queue; |
||||||
|
$this->queue = []; |
||||||
|
$nodes = $this->entityTypeManager->getStorage('node')->loadMultiple($nids); |
||||||
|
|
||||||
|
foreach ($nodes as $node) { |
||||||
|
try { |
||||||
|
if (!$this->exportNode($node)) { |
||||||
|
$this->logger->error('Failed to export node @nid to OCFL.', ['@nid' => $node->id()]); |
||||||
|
} |
||||||
|
} |
||||||
|
catch (\Exception $e) { |
||||||
|
$this->logger->error('Failed to export node @nid to OCFL: @message', [ |
||||||
|
'@nid' => $node->id(), |
||||||
|
'@message' => $e->getMessage(), |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Whether a node should be exported automatically. |
||||||
|
* |
||||||
|
* @param \Drupal\node\NodeInterface $node |
||||||
|
* The node. |
||||||
|
* |
||||||
|
* @return bool |
||||||
|
* TRUE if automatic export is enabled for the node's bundle. |
||||||
|
*/ |
||||||
|
public function shouldAutoExportNode(NodeInterface $node): bool { |
||||||
|
$config = $this->configFactory->get('ocfl_export.settings'); |
||||||
|
if (!$config->get('auto_export_enabled')) { |
||||||
|
return FALSE; |
||||||
|
} |
||||||
|
$types = $config->get('content_types') ?: []; |
||||||
|
return empty($types) || in_array($node->bundle(), $types, TRUE); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Whether a media entity should be exported automatically. |
||||||
|
* |
||||||
|
* @param \Drupal\media\MediaInterface $media |
||||||
|
* The media entity. |
||||||
|
* |
||||||
|
* @return bool |
||||||
|
* TRUE if automatic export is enabled for the media's bundle. |
||||||
|
*/ |
||||||
|
public function shouldAutoExportMedia(MediaInterface $media): bool { |
||||||
|
$config = $this->configFactory->get('ocfl_export.settings'); |
||||||
|
if (!$config->get('auto_export_media_enabled')) { |
||||||
|
return FALSE; |
||||||
|
} |
||||||
|
$types = $config->get('media_types') ?: []; |
||||||
|
return empty($types) || in_array($media->bundle(), $types, TRUE); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Find the media associated with a node. |
||||||
|
* |
||||||
|
* @param \Drupal\node\NodeInterface $node |
||||||
|
* The node. |
||||||
|
* |
||||||
|
* @return array |
||||||
|
* Keyed by media ID; each value has 'media' (the entity) and |
||||||
|
* 'association' (a list such as ['media_of', 'field:field_image']). |
||||||
|
*/ |
||||||
|
protected function getAssociatedMedia(NodeInterface $node): array { |
||||||
|
$associated = []; |
||||||
|
$mediaStorage = $this->entityTypeManager->getStorage('media'); |
||||||
|
|
||||||
|
// Islandora: media point at the node they belong to. |
||||||
|
$mediaOfField = $this->configFactory->get('ocfl_export.settings')->get('media_of_field'); |
||||||
|
$mediaFieldStorage = $this->entityFieldManager->getFieldStorageDefinitions('media'); |
||||||
|
if ($mediaOfField && isset($mediaFieldStorage[$mediaOfField])) { |
||||||
|
$mids = $mediaStorage->getQuery() |
||||||
|
->accessCheck(FALSE) |
||||||
|
->condition($mediaOfField, $node->id()) |
||||||
|
->sort('mid') |
||||||
|
->execute(); |
||||||
|
foreach ($mediaStorage->loadMultiple($mids) as $mid => $media) { |
||||||
|
$associated[$mid] = ['media' => $media, 'association' => ['media_of']]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Plain Drupal: the node references media from its own fields. |
||||||
|
foreach ($node->getFieldDefinitions() as $fieldName => $definition) { |
||||||
|
if ($definition->getType() !== 'entity_reference' || $definition->getSetting('target_type') !== 'media') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
foreach ($node->get($fieldName)->referencedEntities() as $media) { |
||||||
|
$mid = (int) $media->id(); |
||||||
|
$associated[$mid]['media'] = $media; |
||||||
|
$associated[$mid]['association'][] = 'field:' . $fieldName; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
ksort($associated); |
||||||
|
return $associated; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Collect the files held in an entity's file and image fields. |
||||||
|
* |
||||||
|
* @param \Drupal\Core\Entity\FieldableEntityInterface $entity |
||||||
|
* The entity. |
||||||
|
* @param string $prefix |
||||||
|
* Logical directory the files are placed under. |
||||||
|
* @param bool $groupByField |
||||||
|
* Whether to add a sub-directory per field. |
||||||
|
* @param array $files |
||||||
|
* Map of logical path => file URI, added to in place. |
||||||
|
* @param string[] $skipFields |
||||||
|
* Field names to ignore. |
||||||
|
* |
||||||
|
* @return array |
||||||
|
* Descriptions of the files for inclusion in node.json. |
||||||
|
*/ |
||||||
|
protected function collectEntityFiles(FieldableEntityInterface $entity, string $prefix, bool $groupByField, array &$files, array $skipFields = []): array { |
||||||
|
$descriptions = []; |
||||||
|
|
||||||
|
foreach ($entity->getFieldDefinitions() as $fieldName => $definition) { |
||||||
|
if (!in_array($definition->getType(), static::FILE_FIELD_TYPES, TRUE) || in_array($fieldName, $skipFields, TRUE)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
foreach ($entity->get($fieldName) as $delta => $item) { |
||||||
|
$file = $item->entity; |
||||||
|
if (!$file instanceof FileInterface) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
$dir = $groupByField ? $prefix . '/' . $fieldName : $prefix; |
||||||
|
$logicalPath = $this->uniqueLogicalPath($dir, $file, $files); |
||||||
|
$files[$logicalPath] = $file->getFileUri(); |
||||||
|
|
||||||
|
$descriptions[] = [ |
||||||
|
'logical_path' => $this->includeFiles() ? $logicalPath : NULL, |
||||||
|
'field' => $fieldName, |
||||||
|
'delta' => $delta, |
||||||
|
'fid' => (int) $file->id(), |
||||||
|
'uuid' => $file->uuid(), |
||||||
|
'filename' => $file->getFilename(), |
||||||
|
'filemime' => $file->getMimeType(), |
||||||
|
'filesize' => (int) $file->getSize(), |
||||||
|
'uri' => $file->getFileUri(), |
||||||
|
]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $descriptions; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Build a safe logical path that doesn't collide with one already used. |
||||||
|
* |
||||||
|
* @param string $dir |
||||||
|
* The logical directory. |
||||||
|
* @param \Drupal\file\FileInterface $file |
||||||
|
* The file. |
||||||
|
* @param array $files |
||||||
|
* Logical paths already in use, as keys. |
||||||
|
* |
||||||
|
* @return string |
||||||
|
* The logical path. |
||||||
|
*/ |
||||||
|
protected function uniqueLogicalPath(string $dir, FileInterface $file, array $files): string { |
||||||
|
$name = preg_replace('/[\x00-\x1F\x7F\/\\\\]/u', '_', trim((string) $file->getFilename())); |
||||||
|
if ($name === '' || $name === '.' || $name === '..') { |
||||||
|
$name = 'file-' . $file->id(); |
||||||
|
} |
||||||
|
|
||||||
|
$path = $dir . '/' . $name; |
||||||
|
if (!isset($files[$path])) { |
||||||
|
return $path; |
||||||
|
} |
||||||
|
|
||||||
|
$info = pathinfo($name); |
||||||
|
$extension = isset($info['extension']) ? '.' . $info['extension'] : ''; |
||||||
|
return $dir . '/' . $info['filename'] . '-' . $file->id() . $extension; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Whether binary files should be copied into objects. |
||||||
|
*/ |
||||||
|
protected function includeFiles(): bool { |
||||||
|
return (bool) ($this->configFactory->get('ocfl_export.settings')->get('include_files') ?? TRUE); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue