Browse Source

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 <noreply@anthropic.com>
ocfl-files-and-hierarchy
Paul Pound 5 days ago
parent
commit
492adf7bd1
  1. 1
      ocfl_export.services.yml
  2. 3
      src/MediaSerializerService.php
  3. 160
      src/NodeSerializerService.php

1
ocfl_export.services.yml

@ -13,6 +13,7 @@ services:
arguments: arguments:
- '@entity_type.manager' - '@entity_type.manager'
- '@datetime.time' - '@datetime.time'
- '@config.factory'
ocfl_export.media_serializer: ocfl_export.media_serializer:
class: Drupal\ocfl_export\MediaSerializerService class: Drupal\ocfl_export\MediaSerializerService

3
src/MediaSerializerService.php

@ -38,6 +38,7 @@ class MediaSerializerService {
$data = [ $data = [
'uuid' => $media->uuid(), 'uuid' => $media->uuid(),
'mid' => (int) $media->id(),
'type' => $media->bundle(), 'type' => $media->bundle(),
'name' => $media->getName(), 'name' => $media->getName(),
'created' => $this->formatTimestamp((int) $media->getCreatedTime()), 'created' => $this->formatTimestamp((int) $media->getCreatedTime()),
@ -363,6 +364,7 @@ class MediaSerializerService {
protected function serializeMediaReference(MediaInterface $media): array { protected function serializeMediaReference(MediaInterface $media): array {
$data = [ $data = [
'uuid' => $media->uuid(), 'uuid' => $media->uuid(),
'mid' => (int) $media->id(),
'bundle' => $media->bundle(), 'bundle' => $media->bundle(),
'name' => $media->getName(), 'name' => $media->getName(),
'status' => $media->isPublished(), 'status' => $media->isPublished(),
@ -419,6 +421,7 @@ class MediaSerializerService {
protected function serializeNodeReference(NodeInterface $node): array { protected function serializeNodeReference(NodeInterface $node): array {
return [ return [
'uuid' => $node->uuid(), 'uuid' => $node->uuid(),
'nid' => (int) $node->id(),
'type' => $node->bundle(), 'type' => $node->bundle(),
'title' => $node->getTitle(), 'title' => $node->getTitle(),
'status' => $node->isPublished(), 'status' => $node->isPublished(),

160
src/NodeSerializerService.php

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Drupal\ocfl_export; namespace Drupal\ocfl_export;
use Drupal\Component\Datetime\TimeInterface; use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\FieldItemListInterface;
use Drupal\media\MediaInterface; use Drupal\media\MediaInterface;
@ -18,9 +19,15 @@ use Drupal\user\UserInterface;
*/ */
class NodeSerializerService { class NodeSerializerService {
/**
* Maximum number of ancestors followed when building hierarchy paths.
*/
protected const MAX_HIERARCHY_DEPTH = 50;
public function __construct( public function __construct(
protected EntityTypeManagerInterface $entityTypeManager, protected EntityTypeManagerInterface $entityTypeManager,
protected TimeInterface $time, protected TimeInterface $time,
protected ConfigFactoryInterface $configFactory,
) {} ) {}
/** /**
@ -37,6 +44,7 @@ class NodeSerializerService {
$data = [ $data = [
'uuid' => $node->uuid(), 'uuid' => $node->uuid(),
'nid' => (int) $node->id(),
'type' => $node->bundle(), 'type' => $node->bundle(),
'title' => $node->getTitle(), 'title' => $node->getTitle(),
'created' => $this->formatTimestamp((int) $node->getCreatedTime()), 'created' => $this->formatTimestamp((int) $node->getCreatedTime()),
@ -47,6 +55,7 @@ class NodeSerializerService {
'name' => $author->getAccountName(), 'name' => $author->getAccountName(),
'display_name' => $author->getDisplayName(), 'display_name' => $author->getDisplayName(),
], ],
'relationships' => $this->serializeRelationships($node),
'fields' => $this->serializeFields($node), 'fields' => $this->serializeFields($node),
'_metadata' => [ '_metadata' => [
'ocfl_version' => '1.1', 'ocfl_version' => '1.1',
@ -58,6 +67,155 @@ class NodeSerializerService {
return $data; 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. * Serialize all fields of a node.
* *
@ -301,6 +459,7 @@ class NodeSerializerService {
protected function serializeMedia(MediaInterface $media): array { protected function serializeMedia(MediaInterface $media): array {
$data = [ $data = [
'uuid' => $media->uuid(), 'uuid' => $media->uuid(),
'mid' => (int) $media->id(),
'name' => $media->getName(), 'name' => $media->getName(),
'bundle' => $media->bundle(), 'bundle' => $media->bundle(),
]; ];
@ -355,6 +514,7 @@ class NodeSerializerService {
protected function serializeNodeReference(NodeInterface $node): array { protected function serializeNodeReference(NodeInterface $node): array {
return [ return [
'uuid' => $node->uuid(), 'uuid' => $node->uuid(),
'nid' => (int) $node->id(),
'type' => $node->bundle(), 'type' => $node->bundle(),
'title' => $node->getTitle(), 'title' => $node->getTitle(),
'status' => $node->isPublished(), 'status' => $node->isPublished(),

Loading…
Cancel
Save