From 492adf7bd11a8eb6a104400bf45557bf9cc2771f Mon Sep 17 00:00:00 2001 From: Paul Pound Date: Tue, 20 Oct 2026 15:04:00 -0300 Subject: [PATCH] Record Islandora collection hierarchy and model in node.json Add a relationships block to node.json: - model: the model term (tid, uuid, name and external URI when present), - member_of: the node's direct parents (nid, uuid, title, type, model), - hierarchy: every ancestor path through the member-of field, ordered from the root collection down to the direct parent, so nodes with several parents keep all of their paths. Cycles and very deep chains are guarded against. The field names come from the member_of_field and model_field settings, so sites without Islandora export an empty block. Also export nid for nodes and mid for media, including in references, since they were previously dropped as base fields. Co-Authored-By: Claude Opus 5.5 --- ocfl_export.services.yml | 1 + src/MediaSerializerService.php | 3 + src/NodeSerializerService.php | 160 +++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+) diff --git a/ocfl_export.services.yml b/ocfl_export.services.yml index c7ed67b..e3d3361 100644 --- a/ocfl_export.services.yml +++ b/ocfl_export.services.yml @@ -13,6 +13,7 @@ services: arguments: - '@entity_type.manager' - '@datetime.time' + - '@config.factory' ocfl_export.media_serializer: class: Drupal\ocfl_export\MediaSerializerService diff --git a/src/MediaSerializerService.php b/src/MediaSerializerService.php index e02ebf1..2c5ca06 100644 --- a/src/MediaSerializerService.php +++ b/src/MediaSerializerService.php @@ -38,6 +38,7 @@ class MediaSerializerService { $data = [ 'uuid' => $media->uuid(), + 'mid' => (int) $media->id(), 'type' => $media->bundle(), 'name' => $media->getName(), 'created' => $this->formatTimestamp((int) $media->getCreatedTime()), @@ -363,6 +364,7 @@ class MediaSerializerService { protected function serializeMediaReference(MediaInterface $media): array { $data = [ 'uuid' => $media->uuid(), + 'mid' => (int) $media->id(), 'bundle' => $media->bundle(), 'name' => $media->getName(), 'status' => $media->isPublished(), @@ -419,6 +421,7 @@ class MediaSerializerService { protected function serializeNodeReference(NodeInterface $node): array { return [ 'uuid' => $node->uuid(), + 'nid' => (int) $node->id(), 'type' => $node->bundle(), 'title' => $node->getTitle(), 'status' => $node->isPublished(), diff --git a/src/NodeSerializerService.php b/src/NodeSerializerService.php index e6c0189..77fa289 100644 --- a/src/NodeSerializerService.php +++ b/src/NodeSerializerService.php @@ -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(),