@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Drupal\ocfl_export;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\media\MediaInterface;
@ -18,9 +19,15 @@ use Drupal\user\UserInterface;
*/
class NodeSerializerService {
/**
* Maximum number of ancestors followed when building hierarchy paths.
*/
protected const MAX_HIERARCHY_DEPTH = 50;
public function __construct(
protected EntityTypeManagerInterface $entityTypeManager,
protected TimeInterface $time,
protected ConfigFactoryInterface $configFactory,
) {}
/**
@ -37,6 +44,7 @@ class NodeSerializerService {
$data = [
'uuid' => $node->uuid(),
'nid' => (int) $node->id(),
'type' => $node->bundle(),
'title' => $node->getTitle(),
'created' => $this->formatTimestamp((int) $node->getCreatedTime()),
@ -47,6 +55,7 @@ class NodeSerializerService {
'name' => $author->getAccountName(),
'display_name' => $author->getDisplayName(),
],
'relationships' => $this->serializeRelationships($node),
'fields' => $this->serializeFields($node),
'_metadata' => [
'ocfl_version' => '1.1',
@ -58,6 +67,155 @@ class NodeSerializerService {
return $data;
}
/**
* Serialize Islandora-style membership (isMemberOf) and model information.
*
* @param \Drupal\node\NodeInterface $node
* The node.
*
* @return array
* An array with 'model', 'member_of' (direct parents) and 'hierarchy'
* (every ancestor path, each ordered from the root down to the direct
* parent). Keys are omitted when the corresponding field is not present.
*/
protected function serializeRelationships(NodeInterface $node): array {
$config = $this->configFactory->get('ocfl_export.settings');
$modelField = $config->get('model_field');
$memberOfField = $config->get('member_of_field');
$relationships = [];
if ($modelField & & $node->hasField($modelField)) {
$relationships['model'] = $this->getModel($node);
}
if ($memberOfField & & $node->hasField($memberOfField)) {
$relationships['member_of'] = array_map(
fn (NodeInterface $parent) => $this->summarizeHierarchyNode($parent),
$this->getMemberOfParents($node),
);
$relationships['hierarchy'] = $this->buildAncestorPaths($node);
}
return $relationships;
}
/**
* Get the parent nodes a node is a member of.
*
* @param \Drupal\node\NodeInterface $node
* The node.
*
* @return \Drupal\node\NodeInterface[]
* The parent nodes.
*/
protected function getMemberOfParents(NodeInterface $node): array {
$field = $this->configFactory->get('ocfl_export.settings')->get('member_of_field');
if (!$field || !$node->hasField($field)) {
return [];
}
return array_values(array_filter(
$node->get($field)->referencedEntities(),
fn ($entity) => $entity instanceof NodeInterface,
));
}
/**
* Build every ancestor path for a node via the member-of field.
*
* @param \Drupal\node\NodeInterface $node
* The node.
* @param int[] $visited
* Node IDs already on the current path, to guard against cycles.
*
* @return array
* A list of paths. Each path is a list of node summaries ordered from the
* root collection down to the node's direct parent.
*/
protected function buildAncestorPaths(NodeInterface $node, array $visited = []): array {
$visited[] = (int) $node->id();
if (count($visited) > static::MAX_HIERARCHY_DEPTH) {
return [];
}
$paths = [];
foreach ($this->getMemberOfParents($node) as $parent) {
if (in_array((int) $parent->id(), $visited, TRUE)) {
continue;
}
$summary = $this->summarizeHierarchyNode($parent);
$parentPaths = $this->buildAncestorPaths($parent, $visited);
if (!$parentPaths) {
$paths[] = [$summary];
continue;
}
foreach ($parentPaths as $path) {
$path[] = $summary;
$paths[] = $path;
}
}
return $paths;
}
/**
* Summarize a node for membership and hierarchy listings.
*
* @param \Drupal\node\NodeInterface $node
* The node.
*
* @return array
* The node summary.
*/
protected function summarizeHierarchyNode(NodeInterface $node): array {
$summary = [
'nid' => (int) $node->id(),
'uuid' => $node->uuid(),
'title' => $node->getTitle(),
'type' => $node->bundle(),
];
$model = $this->getModel($node);
if ($model) {
$summary['model'] = $model['name'];
}
return $summary;
}
/**
* Get the Islandora model term of a node.
*
* @param \Drupal\node\NodeInterface $node
* The node.
*
* @return array|null
* The model name, term UUID and external URI (if any), or NULL.
*/
protected function getModel(NodeInterface $node): ?array {
$field = $this->configFactory->get('ocfl_export.settings')->get('model_field');
if (!$field || !$node->hasField($field)) {
return NULL;
}
$term = $node->get($field)->entity;
if (!$term instanceof TermInterface) {
return NULL;
}
$model = [
'tid' => (int) $term->id(),
'uuid' => $term->uuid(),
'name' => $term->getName(),
];
if ($term->hasField('field_external_uri') & & !$term->get('field_external_uri')->isEmpty()) {
$model['uri'] = $term->get('field_external_uri')->uri;
}
return $model;
}
/**
* Serialize all fields of a node.
*
@ -301,6 +459,7 @@ class NodeSerializerService {
protected function serializeMedia(MediaInterface $media): array {
$data = [
'uuid' => $media->uuid(),
'mid' => (int) $media->id(),
'name' => $media->getName(),
'bundle' => $media->bundle(),
];
@ -355,6 +514,7 @@ class NodeSerializerService {
protected function serializeNodeReference(NodeInterface $node): array {
return [
'uuid' => $node->uuid(),
'nid' => (int) $node->id(),
'type' => $node->bundle(),
'title' => $node->getTitle(),
'status' => $node->isPublished(),