Browse Source

first commit

main
Paul Pound 5 months ago
commit
29020ffd98
  1. 5
      ocfl_storage.info.yml
  2. 99
      ocfl_storage.module
  3. 19
      ocfl_storage.routing.yml
  4. 4
      ocfl_storage.services.yml
  5. 189
      src/Controller/OcflStorageController.php
  6. 10
      src/Ocfl/Exception/OcflException.php
  7. 124
      src/Ocfl/Filesystem/FilesystemAdapterInterface.php
  8. 206
      src/Ocfl/Filesystem/LocalFilesystemAdapter.php
  9. 197
      src/Ocfl/Model/OcflInventory.php
  10. 109
      src/Ocfl/Model/OcflObject.php
  11. 15
      src/Ocfl/OcflConstants.php
  12. 223
      src/Ocfl/OcflObjectManager.php
  13. 302
      src/OcflStorageService.php
  14. 30
      src/ParamConverter/OcflObjectIdConverter.php

5
ocfl_storage.info.yml

@ -0,0 +1,5 @@
name: OCFL Storage
type: module
description: Provides OCFL-like storage for files and node metadata.
core_version_requirement: ^10 || ^11
package: Custom

99
ocfl_storage.module

@ -0,0 +1,99 @@
<?php
/**
* @file
* Primary module hooks for OCFL Storage module.
*/
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\node\NodeInterface;
/**
* Implements hook_node_insert().
*/
function ocfl_storage_node_insert(NodeInterface $node) {
\Drupal::service('ocfl_storage.ocfl_service')->saveNodeToOcfl($node);
}
/**
* Implements hook_node_update().
*/
function ocfl_storage_node_update(NodeInterface $node) {
\Drupal::service('ocfl_storage.ocfl_service')->saveNodeToOcfl($node);
}
/**
* Implements hook_node_delete().
*
* Note: Actual OCFL implementations typically mark objects as deleted or
* create a new version indicating deletion, rather than physically removing them.
* For this simplified module, we'll just log the deletion.
*/
function ocfl_storage_node_delete(NodeInterface $node) {
\Drupal::logger('ocfl_storage')->notice('Node %title (%nid) deleted. OCFL object will not be physically removed by this module, but a new version could be created to reflect deletion.', [
'%title' => $node->label(),
'%nid' => $node->id(),
]);
}
/**
* Implements hook_help().
*/
function ocfl_storage_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.ocfl_storage':
$output = '<h3>' . t('About') . '</h3>';
$output .= '<p>' . t('The OCFL Storage module provides a simplified, OCFL-like file system storage for uploaded files and node metadata. It aims to demonstrate how content could be versioned and managed in a structured way outside of Drupal\'s default file system.') . '</p>';
$output .= '<h3>' . t('Usage') . '</h3>';
$output .= '<p>' . t('When a node is saved or updated, this module attempts to store its fields (as JSON metadata) and any attached files within an OCFL-like structure in your Drupal private files directory. You can browse the stored objects via the "Browse OCFL Objects" link under Content.') . '</p>';
return $output;
case 'ocfl_storage.browse':
return '<p>' . t('This page lists all OCFL-like objects currently stored by the module.') . '</p>';
case 'ocfl_storage.object_view':
return '<p>' . t('Details for a specific OCFL-like object, including its versions and contents.') . '</p>';
}
}
/**
* Implements hook_menu_links_alter().
*/
function ocfl_storage_menu_links_alter(&$links) {
if (isset($links['system.admin_content'])) {
$links['ocfl_storage.browse'] = [
'title' => t('Browse OCFL Objects'),
'route_name' => 'ocfl_storage.browse',
'parent' => 'system.admin_content',
'weight' => 100,
'expanded' => FALSE,
];
}
}
/**
* Implements hook_local_tasks_alter().
*/
function ocfl_storage_local_tasks_alter(&$local_tasks, $route_name) {
if ($route_name == 'ocfl_storage.browse') {
// Add a tab for the main browse page if needed.
$local_tasks['ocfl_storage.browse']['title'] = t('Browse Objects');
$local_tasks['ocfl_storage.browse']['base_route'] = 'ocfl_storage.browse';
}
if ($route_name == 'ocfl_storage.object_view') {
// Add a tab for the object view page.
$local_tasks['ocfl_storage.object_view']['title'] = t('Object Details');
$local_tasks['ocfl_storage.object_view']['base_route'] = 'ocfl_storage.object_view';
}
}
/**
* Implements hook_entity_type_alter().
*
* This hook is used to define a custom parameter converter for OCFL object IDs.
*/
function ocfl_storage_entity_type_alter(array &$entity_types) {
if (isset($entity_types['ocfl_object_id'])) {
$entity_types['ocfl_object_id']->setHandlerClass('param_converter', 'Drupal\ocfl_storage\ParamConverter\OcflObjectIdConverter');
}
}

19
ocfl_storage.routing.yml

@ -0,0 +1,19 @@
ocfl_storage.browse:
path: '/admin/content/Ocfl-browse'
defaults:
_controller: '\Drupal\ocfl_storage\Controller\OcflStorageController::browse'
_title: 'Browse OCFL Objects'
requirements:
_permission: 'access content'
ocfl_storage.object_view:
path: '/admin/content/Ocfl-browse/{object_id}'
defaults:
_controller: '\Drupal\ocfl_storage\Controller\OcflStorageController::viewObject'
_title: 'OCFL Object Details'
requirements:
_permission: 'access content'
options:
parameters:
object_id:
type: ocfl_object_id

4
ocfl_storage.services.yml

@ -0,0 +1,4 @@
services:
ocfl_storage.ocfl_service:
class: Drupal\ocfl_storage\OcflStorageService
arguments: ['@file_system', '@logger.factory', '@entity_type.manager']

189
src/Controller/OcflStorageController.php

@ -0,0 +1,189 @@
<?php
namespace Drupal\ocfl_storage\Controller;
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\ocfl_storage\OcflStorageService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Controller for OCFL storage browsing.
*/
class OcflStorageController extends ControllerBase {
/**
* The OCFL storage service.
*
* @var \Drupal\ocfl_storage\OcflStorageService
*/
protected $ocflService;
/**
* Constructs an OcflStorageController object.
*
* @param \Drupal\ocfl_storage\OcflStorageService $ocfl_service
* The OCFL storage service.
*/
public function __construct(OcflStorageService $ocfl_service) {
$this->ocflService = $ocfl_service;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('ocfl_storage.ocfl_service')
);
}
/**
* Displays a list of OCFL objects.
*
* @return array
* A render array.
*/
public function browse() {
$objects = $this->ocflService->listOcflObjects();
$rows = [];
if (empty($objects)) {
$rows[] = [
['data' => $this->t('No OCFL objects found.'), 'colspan' => 2],
];
}
else {
foreach ($objects as $object_id) {
$object_details = $this->ocflService->getObjectDetails($object_id);
$latest_version_num = $this->ocflService->getCurrentVersion($object_id);
$latest_version_info = $object_details['versions']['v' . $latest_version_num]['metadata'] ?? [];
$rows[] = [
'data' => [
\Drupal\Core\Link::fromTextAndUrl($object_id, \Drupal\Core\Url::fromRoute('ocfl_storage.object_view', ['object_id' => $object_id])),
$latest_version_info['title'] ?? $this->t('N/A'),
$latest_version_num,
\Drupal::service('date.formatter')->format($latest_version_info['changed'] ?? 0, 'medium'),
],
];
}
}
$header = [
$this->t('Object ID'),
$this->t('Node Title (Latest)'),
$this->t('Latest Version'),
$this->t('Last Modified'),
];
return [
'#type' => 'table',
'#header' => $header,
'#rows' => $rows,
'#empty' => $this->t('No OCFL objects found.'),
];
}
/**
* Displays details for a specific OCFL object.
*
* @param string $object_id
* The OCFL object ID.
*
* @return array
* A render array.
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
* If the object is not found.
*/
public function viewObject(string $object_id) {
$object_details = $this->ocflService->getObjectDetails($object_id);
if (!$object_details) {
throw new NotFoundHttpException();
}
$build = [
'#markup' => '<h2>' . $this->t('Details for OCFL Object: @id', ['@id' => $object_id]) . '</h2>',
];
foreach ($object_details['versions'] as $version_key => $version_info) {
$metadata = $version_info['metadata'] ?? [];
$files = $metadata['files'] ?? [];
$fields = $metadata['fields'] ?? [];
$build[$version_key] = [
'#type' => 'details',
'#title' => $this->t('Version @version_num (@created)', [
'@version_num' => $version_key,
'@created' => \Drupal::service('date.formatter')->format($version_info['created'] ?? 0, 'medium'),
]),
'#open' => TRUE,
'metadata_summary' => [
'#markup' => $this->t('<strong>Message:</strong> @message<br><strong>User:</strong> @user_name (@user_id)', [
'@message' => $version_info['message'] ?? $this->t('N/A'),
'@user_name' => $version_info['user']['name'] ?? $this->t('Anonymous'),
'@user_id' => $version_info['user']['id'] ?? $this->t('N/A'),
]),
],
'files_section' => [
'#type' => 'fieldset',
'#title' => $this->t('Attached Files'),
'#collapsible' => TRUE,
'#collapsed' => empty($files),
],
'fields_section' => [
'#type' => 'fieldset',
'#title' => $this->t('Node Fields Metadata'),
'#collapsible' => TRUE,
'#collapsed' => FALSE,
],
];
// Files table.
$file_rows = [];
if (empty($files)) {
$file_rows[] = [['data' => $this->t('No files attached to this version.'), 'colspan' => 4]];
}
else {
foreach ($files as $file) {
$file_rows[] = [
$file['filename'] ?? $this->t('N/A'),
$file['mimetype'] ?? $this->t('N/A'),
$file['size'] ?? $this->t('N/A'),
$file['ocfl_path'] ?? $this->t('N/A'),
];
}
}
$build[$version_key]['files_section']['files_table'] = [
'#type' => 'table',
'#header' => [$this->t('Filename'), $this->t('MIME Type'), $this->t('Size'), $this->t('OCFL Path')],
'#rows' => $file_rows,
];
// Fields table.
$field_rows = [];
if (empty($fields)) {
$field_rows[] = [['data' => $this->t('No fields metadata for this version.'), 'colspan' => 2]];
}
else {
foreach ($fields as $field_name => $field_value) {
$field_rows[] = [
$field_name,
json_encode($field_value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
];
}
}
$build[$version_key]['fields_section']['fields_table'] = [
'#type' => 'table',
'#header' => [$this->t('Field Name'), $this->t('Value')],
'#rows' => $field_rows,
];
}
return $build;
}
}

10
src/Ocfl/Exception/OcflException.php

@ -0,0 +1,10 @@
<?php
namespace Ocfl\Exception;
/**
* Base exception for OCFL library errors.
*/
class OcflException extends \Exception
{
}

124
src/Ocfl/Filesystem/FilesystemAdapterInterface.php

@ -0,0 +1,124 @@
<?php
namespace Ocfl\Filesystem;
use Ocfl\Exception\OcflException;
/**
* Interface for abstracting filesystem operations.
* This allows the OCFL library to work with different storage backends
* (e.g., local disk, S3, etc.).
*/
interface FilesystemAdapterInterface
{
/**
* Creates a directory.
*
* @param string $path The path to the directory to create.
* @param int $mode The permissions mode.
* @param bool $recursive Whether to create parent directories recursively.
* @return bool True on success.
* @throws OcflException If the directory cannot be created.
*/
public function createDirectory(string $path, int $mode = 0777, bool $recursive = true): bool;
/**
* Writes content to a file.
*
* @param string $path The path to the file.
* @param string $content The content to write.
* @return int The number of bytes written.
* @throws OcflException If the file cannot be written.
*/
public function putContents(string $path, string $content): int;
/**
* Reads content from a file.
*
* @param string $path The path to the file.
* @return string The content of the file.
* @throws OcflException If the file cannot be read or does not exist.
*/
public function getContents(string $path): string;
/**
* Checks if a path exists.
*
* @param string $path The path to check.
* @return bool True if the path exists, false otherwise.
*/
public function exists(string $path): bool;
/**
* Checks if a path is a directory.
*
* @param string $path The path to check.
* @return bool True if the path is a directory, false otherwise.
*/
public function isDirectory(string $path): bool;
/**
* Checks if a path is a file.
*
* @param string $path The path to check.
* @return bool True if the path is a file, false otherwise.
*/
public function isFile(string $path): bool;
/**
* Lists the contents of a directory.
*
* @param string $path The path to the directory.
* @return array An array of filenames and directory names (not full paths).
* @throws OcflException If the path is not a directory or cannot be read.
*/
public function listContents(string $path): array;
/**
* Deletes a file or an empty directory.
*
* @param string $path The path to delete.
* @return bool True on success.
* @throws OcflException If the path cannot be deleted.
*/
public function delete(string $path): bool;
/**
* Recursively deletes a directory and its contents.
*
* @param string $path The path to the directory to delete.
* @return bool True on success.
* @throws OcflException If the directory cannot be deleted.
*/
public function deleteRecursive(string $path): bool;
/**
* Copies a file.
*
* @param string $sourcePath The source file path.
* @param string $destinationPath The destination file path.
* @return bool True on success.
* @throws OcflException If the file cannot be copied.
*/
public function copy(string $sourcePath, string $destinationPath): bool;
/**
* Calculates the digest (checksum) of a file.
*
* @param string $path The path to the file.
* @param string $algorithm The digest algorithm (e.g., 'sha512', 'md5').
* @return string The calculated digest.
* @throws OcflException If the file does not exist or digest cannot be calculated.
*/
public function hashFile(string $path, string $algorithm): string;
/**
* Creates a symbolic link.
*
* @param string $target The target of the link.
* @param string $linkPath The path to the symbolic link.
* @return bool True on success.
* @throws OcflException If the symlink cannot be created.
*/
public function symlink(string $target, string $linkPath): bool;
}

206
src/Ocfl/Filesystem/LocalFilesystemAdapter.php

@ -0,0 +1,206 @@
<?php
namespace Ocfl\Filesystem;
use Ocfl\Exception\OcflException;
/**
* Filesystem adapter for local disk operations.
*/
class LocalFilesystemAdapter implements FilesystemAdapterInterface
{
/**
* @inheritDoc
*/
public function createDirectory(string $path, int $mode = 0777, bool $recursive = true): bool
{
if (file_exists($path)) {
if (is_dir($path)) {
return true; // Already exists
}
throw new OcflException("Path '{$path}' exists and is not a directory.");
}
if (!mkdir($path, $mode, $recursive)) {
throw new OcflException("Failed to create directory: {$path}");
}
return true;
}
/**
* @inheritDoc
*/
public function putContents(string $path, string $content): int
{
$dir = dirname($path);
if (!is_dir($dir)) {
$this->createDirectory($dir);
}
$bytesWritten = file_put_contents($path, $content);
if ($bytesWritten === false) {
throw new OcflException("Failed to write content to file: {$path}");
}
return $bytesWritten;
}
/**
* @inheritDoc
*/
public function getContents(string $path): string
{
if (!$this->isFile($path)) {
throw new OcflException("File not found or is not a file: {$path}");
}
$content = file_get_contents($path);
if ($content === false) {
throw new OcflException("Failed to read content from file: {$path}");
}
return $content;
}
/**
* @inheritDoc
*/
public function exists(string $path): bool
{
return file_exists($path);
}
/**
* @inheritDoc
*/
public function isDirectory(string $path): bool
{
return is_dir($path);
}
/**
* @inheritDoc
*/
public function isFile(string $path): bool
{
return is_file($path);
}
/**
* @inheritDoc
*/
public function listContents(string $path): array
{
if (!$this->isDirectory($path)) {
throw new OcflException("Path '{$path}' is not a directory.");
}
$contents = scandir($path);
if ($contents === false) {
throw new OcflException("Failed to list contents of directory: {$path}");
}
// Remove '.' and '..'
return array_values(array_diff($contents, ['.', '..']));
}
/**
* @inheritDoc
*/
public function delete(string $path): bool
{
if ($this->isFile($path)) {
if (!unlink($path)) {
throw new OcflException("Failed to delete file: {$path}");
}
return true;
} elseif ($this->isDirectory($path)) {
if (count($this->listContents($path)) > 0) {
throw new OcflException("Directory '{$path}' is not empty. Use deleteRecursive to remove non-empty directories.");
}
if (!rmdir($path)) {
throw new OcflException("Failed to delete empty directory: {$path}");
}
return true;
}
return false; // Path does not exist
}
/**
* @inheritDoc
*/
public function deleteRecursive(string $path): bool
{
if (!$this->exists($path)) {
return true; // Already gone
}
if ($this->isFile($path)) {
return $this->delete($path);
}
if (!$this->isDirectory($path)) {
throw new OcflException("Path '{$path}' is not a file or directory.");
}
$items = $this->listContents($path);
foreach ($items as $item) {
$itemPath = $path . DIRECTORY_SEPARATOR . $item;
if ($this->isDirectory($itemPath)) {
$this->deleteRecursive($itemPath);
} else {
$this->delete($itemPath);
}
}
return $this->delete($path);
}
/**
* @inheritDoc
*/
public function copy(string $sourcePath, string $destinationPath): bool
{
if (!$this->isFile($sourcePath)) {
throw new OcflException("Source path '{$sourcePath}' is not a file.");
}
$dir = dirname($destinationPath);
if (!is_dir($dir)) {
$this->createDirectory($dir);
}
if (!copy($sourcePath, $destinationPath)) {
throw new OcflException("Failed to copy file from '{$sourcePath}' to '{$destinationPath}'.");
}
return true;
}
/**
* @inheritDoc
*/
public function hashFile(string $path, string $algorithm): string
{
if (!$this->isFile($path)) {
throw new OcflException("File not found or is not a file for hashing: {$path}");
}
$hash = hash_file($algorithm, $path);
if ($hash === false) {
throw new OcflException("Failed to calculate {$algorithm} hash for file: {$path}");
}
return $hash;
}
/**
* @inheritDoc
*/
public function symlink(string $target, string $linkPath): bool
{
// Ensure the directory for the symlink exists
$dir = dirname($linkPath);
if (!is_dir($dir)) {
$this->createDirectory($dir);
}
// For local filesystem, symlink() is available.
// Note: On Windows, symlink requires elevated privileges unless developer mode is enabled.
// For cross-platform compatibility, consider a copy fallback or specific OS checks.
if (!@symlink($target, $linkPath)) {
// Fallback to copy if symlink fails (e.g., on Windows without dev mode, or different filesystems)
// This is a common approach for OCFL when hard links/symlinks are not feasible.
return $this->copy($target, $linkPath);
}
return true;
}
}

197
src/Ocfl/Model/OcflInventory.php

@ -0,0 +1,197 @@
<?php
namespace Ocfl\Model;
use Ocfl\OcflConstants;
use Ocfl\Filesystem\FilesystemAdapterInterface;
use Ocfl\Exception\OcflException;
/**
* Represents and manages the OCFL inventory.json file.
* This class handles the structure, serialization, and logic related to versions and file states.
*/
class OcflInventory
{
public string $id;
public string $type;
public string $digestAlgorithm;
public string $head;
public string $contentDirectory;
public array $manifest; // path -> { digest: [fixity] }
public array $versions; // vN -> { state: { digest: [path] }, created, message, user }
/**
* Constructor for a new OCFL Inventory.
*
* @param string $objectId The unique ID of the OCFL object.
* @param string $digestAlgorithm The default digest algorithm for fixity.
*/
public function __construct(string $objectId, string $digestAlgorithm = OcflConstants::DEFAULT_DIGEST_ALGORITHM)
{
$this->id = $objectId;
$this->type = 'https://ocfl.io/1.0/spec/#object-root'; // OCFL 1.0 object type
$this->digestAlgorithm = $digestAlgorithm;
$this->head = 'v1'; // Default initial head
$this->contentDirectory = OcflConstants::CONTENT_DIRECTORY;
$this->manifest = [];
$this->versions = [];
}
/**
* Creates an OcflInventory instance from a JSON string.
*
* @param string $jsonString The JSON content of an inventory.json file.
* @return self
* @throws OcflException If the JSON is invalid or missing required fields.
*/
public static function fromJson(string $jsonString): self
{
$data = json_decode($jsonString, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new OcflException("Invalid JSON for OCFL Inventory: " . json_last_error_msg());
}
// Basic validation for required fields
$requiredFields = ['id', 'type', 'digestAlgorithm', 'head', 'contentDirectory', 'manifest', 'versions'];
foreach ($requiredFields as $field) {
if (!isset($data[$field])) {
throw new OcflException("Missing required field '{$field}' in OCFL Inventory JSON.");
}
}
$inventory = new self($data['id'], $data['digestAlgorithm']);
// Assign all properties from the decoded JSON
foreach ($data as $key => $value) {
if (property_exists($inventory, $key)) {
$inventory->$key = $value;
}
}
return $inventory;
}
/**
* Converts the inventory object to a JSON string.
*
* @return string
*/
public function toJson(): string
{
return json_encode($this, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* Gets the numeric value of the head version.
*
* @return int
*/
public function getHeadVersionNumber(): int
{
return (int) substr($this->head, 1);
}
/**
* Gets the state of a specific version (digest to logical path mapping).
*
* @param int $versionNum The version number.
* @return array The state array (digest => [logicalPath]).
*/
public function getVersionState(int $versionNum): array
{
$versionKey = "v{$versionNum}";
return $this->versions[$versionKey]['state'] ?? [];
}
/**
* Adds a new version to the inventory.
* This method handles the logic of determining the new state based on previous versions,
* new files, and deleted files, and updates the manifest.
*
* @param int $versionNum The number of the new version.
* @param array $filesData An array of associative arrays, each with 'path' (logical path) and 'content' (file content).
* @param array $metadata Optional metadata for the version (message, user).
* @param FilesystemAdapterInterface $filesystem The filesystem adapter to use for file operations.
* @param string $objectPath The absolute path to the OCFL object root.
* @param array $previousState The state of the previous version (digest => [logicalPath]).
* @throws OcflException If file operations fail.
*/
public function addVersion(
int $versionNum,
array $filesData,
array $metadata,
FilesystemAdapterInterface $filesystem,
string $objectPath,
array $previousState = []
): void {
$versionKey = "v{$versionNum}";
$contentPhysicalDir = "{$objectPath}/{$versionKey}/{$this->contentDirectory}";
$filesystem->createDirectory($contentPhysicalDir);
$newState = []; // digest -> [logicalPath]
$manifestUpdates = []; // logicalPath -> { digest: [fixity] }
// 1. Process new/modified files
foreach ($filesData as $file) {
$logicalPath = $file['path'];
$content = $file['content'];
$physicalPath = "{$contentPhysicalDir}/{$logicalPath}";
// Ensure parent directories exist for the physical path within the content directory
$physicalDir = dirname($physicalPath);
if (!$filesystem->isDirectory($physicalDir)) {
$filesystem->createDirectory($physicalDir);
}
$filesystem->putContents($physicalPath, $content);
$digest = $filesystem->hashFile($physicalPath, $this->digestAlgorithm);
if (!isset($newState[$digest])) {
$newState[$digest] = [];
}
$newState[$digest][] = $logicalPath;
$manifestUpdates[$logicalPath] = [$this->digestAlgorithm => [$digest]];
}
// 2. Incorporate files from previous version that are not changed/deleted
// This is where OCFL's content addressability and linking (or copying) comes in.
// For simplicity, we are assuming `filesData` already contains all files for the new version,
// including those retained from previous versions.
// A more advanced implementation would explicitly copy/link files from previous versions
// if their logical path and digest haven't changed.
foreach ($previousState as $digest => $logicalPaths) {
foreach ($logicalPaths as $logicalPath) {
// If this logical path was not explicitly handled in new/modified files,
// it means it's retained from the previous version.
if (!isset($manifestUpdates[$logicalPath])) {
if (!isset($newState[$digest])) {
$newState[$digest] = [];
}
$newState[$digest][] = $logicalPath;
$manifestUpdates[$logicalPath] = [$this->digestAlgorithm => [$digest]];
// In a real OCFL implementation, you'd now copy/link the physical file
// from its original location (e.g., previous version's content directory)
// to the new version's content directory if it's not already there.
// For this simulation, we assume the `filesData` array passed to this
// method already contains the content for all files in the new version.
// If the file was retained, its content would have been fetched and added to filesData.
}
}
}
// Update the manifest with all current logical paths and their digests
$this->manifest = array_merge($this->manifest, $manifestUpdates);
// Add the new version entry
$this->versions[$versionKey] = [
'state' => $newState,
'created' => (new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339),
'message' => $metadata['message'] ?? "Version {$versionNum}",
'user' => $metadata['user'] ?? 'Ocfl-php-library-user',
];
$this->head = $versionKey;
}
}

109
src/Ocfl/Model/OcflObject.php

@ -0,0 +1,109 @@
<?php
namespace Ocfl\Model;
use Ocfl\Filesystem\FilesystemAdapterInterface;
use Ocfl\Exception\OcflException;
use Ocfl\OcflConstants;
/**
* Represents an individual OCFL object instance.
* Provides methods to interact with the object's inventory and retrieve file content.
*/
class OcflObject
{
private string $objectPath;
private FilesystemAdapterInterface $filesystem;
private OcflInventory $inventory;
/**
* @param string $objectPath The absolute path to the OCFL object root.
* @param FilesystemAdapterInterface $filesystem The filesystem adapter.
* @param OcflInventory $inventory The parsed OCFL inventory for this object.
*/
public function __construct(string $objectPath, FilesystemAdapterInterface $filesystem, OcflInventory $inventory)
{
$this->objectPath = $objectPath;
$this->filesystem = $filesystem;
$this->inventory = $inventory;
}
/**
* Gets the OCFL Inventory for this object.
*
* @return OcflInventory
*/
public function getInventory(): OcflInventory
{
return $this->inventory;
}
/**
* Gets the absolute path to the OCFL object root.
*
* @return string
*/
public function getPath(): string
{
return $this->objectPath;
}
/**
* Retrieves the content of a specific file from a given version.
*
* @param int $versionNum The version number (e.g., 1, 2, etc.).
* @param string $logicalFilePath The logical path of the file within the object (e.g., 'documents/report.pdf').
* @return string The content of the file.
* @throws OcflException If the version or file is not found, or content cannot be read.
*/
public function getFileContent(int $versionNum, string $logicalFilePath): string
{
$versionKey = "v{$versionNum}";
if (!isset($this->inventory->versions[$versionKey])) {
throw new OcflException("Version '{$versionKey}' not found for object '{$this->inventory->id}'.");
}
$versionState = $this->inventory->versions[$versionKey]['state'];
// Find the digest associated with the logical file path in this version's state
$fileDigest = null;
foreach ($versionState as $digest => $paths) {
if (in_array($logicalFilePath, $paths)) {
$fileDigest = $digest;
break;
}
}
if ($fileDigest === null) {
throw new OcflException("File '{$logicalFilePath}' not found in version '{$versionKey}' of object '{$this->inventory->id}'.");
}
// Determine the physical path. In a simple implementation, it's directly in the content directory.
// In a more complex one with decoupled content, you'd map the digest to a content store path.
$physicalFilePath = "{$this->objectPath}/{$versionKey}/{$this->inventory->contentDirectory}/{$logicalFilePath}";
if (!$this->filesystem->exists($physicalFilePath)) {
// This scenario can happen if files are hard-linked/symlinked from previous versions
// and not physically copied to the current version's content directory.
// We need to trace back to the version where this digest first appeared or was physically stored.
// Iterate backwards from the current version to find the physical file
for ($i = $versionNum; $i >= 1; $i--) {
$checkVersionKey = "v{$i}";
if (isset($this->inventory->versions[$checkVersionKey])) {
$checkVersionState = $this->inventory->versions[$checkVersionKey]['state'];
if (isset($checkVersionState[$fileDigest]) && in_array($logicalFilePath, $checkVersionState[$fileDigest])) {
$checkPhysicalPath = "{$this->objectPath}/{$checkVersionKey}/{$this->inventory->contentDirectory}/{$logicalFilePath}";
if ($this->filesystem->exists($checkPhysicalPath)) {
return $this->filesystem->getContents($checkPhysicalPath);
}
}
}
}
throw new OcflException("Physical file for logical path '{$logicalFilePath}' (digest: {$fileDigest}) not found anywhere in object '{$this->inventory->id}'.");
}
return $this->filesystem->getContents($physicalFilePath);
}
}

15
src/Ocfl/OcflConstants.php

@ -0,0 +1,15 @@
<?php
namespace Ocfl;
/**
* Defines constants used across the OCFL library.
*/
class OcflConstants
{
public const OCFL_VERSION = '1.0';
public const DEFAULT_DIGEST_ALGORITHM = 'sha512';
public const OCFL_OBJECT_DECLARATION_FILENAME = 'ocfl_object_1.0'; // For OCFL 1.0
public const INVENTORY_FILENAME = 'inventory.json';
public const CONTENT_DIRECTORY = 'content';
}

223
src/Ocfl/OcflObjectManager.php

@ -0,0 +1,223 @@
<?php
namespace Ocfl;
use Ocfl\Filesystem\FilesystemAdapterInterface;
use Ocfl\Model\OcflInventory;
use Ocfl\Model\OcflObject;
use Ocfl\Exception\OcflException;
/**
* Manages OCFL objects within a storage root.
* This is the main entry point for creating, opening, and modifying OCFL objects.
*/
class OcflObjectManager
{
private string $storageRoot;
private FilesystemAdapterInterface $filesystem;
/**
* @param string $storageRoot The absolute path to the OCFL storage root.
* @param FilesystemAdapterInterface $filesystem The filesystem adapter to use.
* @throws OcflException If the storage root does not exist or is not a directory.
*/
public function __construct(string $storageRoot, FilesystemAdapterInterface $filesystem)
{
$this->storageRoot = rtrim($storageRoot, DIRECTORY_SEPARATOR);
$this->filesystem = $filesystem;
// Ensure the storage root exists and is a directory
if (!$this->filesystem->exists($this->storageRoot)) {
$this->filesystem->createDirectory($this->storageRoot);
} elseif (!$this->filesystem->isDirectory($this->storageRoot)) {
throw new OcflException("Storage root '{$this->storageRoot}' exists but is not a directory.");
}
// TODO: A full implementation would also manage an OCFL Storage Root Inventory.
// For simplicity, we're skipping that for now.
}
/**
* Creates a new OCFL object.
*
* @param string $objectId The unique identifier for the new object.
* @param array $filesData An array of associative arrays, each with 'path' (logical path) and 'content' (file content) for the initial version.
* @param array $metadata Optional metadata for the initial version (e.g., 'message', 'user').
* @return OcflObject The newly created OCFL object instance.
* @throws OcflException If the object already exists or creation fails.
*/
public function createObject(string $objectId, array $filesData, array $metadata = []): OcflObject
{
$objectPath = $this->getObjectPath($objectId);
if ($this->filesystem->exists($objectPath)) {
throw new OcflException("OCFL Object '{$objectId}' already exists at '{$objectPath}'.");
}
// Create object root directory
$this->filesystem->createDirectory($objectPath);
// Create OCFL object declaration file (e.g., 'ocfl_object_1.0')
$this->filesystem->putContents("{$objectPath}/" . OcflConstants::OCFL_OBJECT_DECLARATION_FILENAME, "");
// Initialize inventory for the first version
$inventory = new OcflInventory($objectId);
$inventory->addVersion(1, $filesData, $metadata, $this->filesystem, $objectPath);
// Write the main inventory.json to the object root
$inventoryJsonPath = "{$objectPath}/" . OcflConstants::INVENTORY_FILENAME;
$this->filesystem->putContents($inventoryJsonPath, $inventory->toJson());
// Create the inventory digest file for v1 in its version directory
$version1Dir = "{$objectPath}/v1";
$this->filesystem->createDirectory($version1Dir);
// OCFL 1.0 requires a digest of the inventory.json in the version directory,
// often a hard link or copy for simplicity in non-content-addressable scenarios.
$this->filesystem->copy($inventoryJsonPath, "{$version1Dir}/" . OcflConstants::INVENTORY_FILENAME);
return new OcflObject($objectPath, $this->filesystem, $inventory);
}
/**
* Opens an existing OCFL object.
*
* @param string $objectId The unique identifier of the object to open.
* @return OcflObject The opened OCFL object instance.
* @throws OcflException If the object does not exist or is not a valid OCFL object.
*/
public function openObject(string $objectId): OcflObject
{
$objectPath = $this->getObjectPath($objectId);
if (!$this->filesystem->isDirectory($objectPath)) {
throw new OcflException("OCFL Object '{$objectId}' not found at '{$objectPath}'.");
}
// Basic validation: Check for OCFL object declaration and inventory.json
if (!$this->filesystem->exists("{$objectPath}/" . OcflConstants::OCFL_OBJECT_DECLARATION_FILENAME)) {
throw new OcflException("Path '{$objectPath}' is not a valid OCFL object root (missing OCFL declaration file).");
}
$inventoryJsonPath = "{$objectPath}/" . OcflConstants::INVENTORY_FILENAME;
if (!$this->filesystem->isFile($inventoryJsonPath)) {
throw new OcflException("Path '{$objectPath}' is not a valid OCFL object root (missing inventory.json).");
}
$inventoryJson = $this->filesystem->getContents($inventoryJsonPath);
$inventory = OcflInventory::fromJson($inventoryJson);
// TODO: Add more robust validation here, e.g., checksum of inventory.json,
// validation against OCFL schema, consistency checks between inventory and physical files.
return new OcflObject($objectPath, $this->filesystem, $inventory);
}
/**
* Adds a new version to an existing OCFL object.
*
* @param string $objectId The unique identifier of the object to modify.
* @param array $newFilesData An array of associative arrays, each with 'path' (logical path) and 'content' (file content) for new or modified files.
* @param array $deletedFiles An array of logical file paths to be deleted in the new version.
* @param array $metadata Optional metadata for the new version (e.g., 'message', 'user').
* @return OcflObject The updated OCFL object instance.
* @throws OcflException If the object does not exist or version creation fails.
*/
public function addVersion(string $objectId, array $newFilesData = [], array $deletedFiles = [], array $metadata = []): OcflObject
{
$object = $this->openObject($objectId);
$inventory = $object->getInventory();
$objectPath = $object->getPath();
$currentHeadVersionNum = $inventory->getHeadVersionNumber();
$nextVersionNumber = $currentHeadVersionNum + 1;
$nextVersionKey = "v{$nextVersionNumber}";
$nextVersionDir = "{$objectPath}/{$nextVersionKey}";
$nextContentDir = "{$nextVersionDir}/" . OcflConstants::CONTENT_DIRECTORY;
// Create new version directory and its content directory
$this->filesystem->createDirectory($nextVersionDir);
$this->filesystem->createDirectory($nextContentDir);
// Determine the state of the previous version
$previousState = $inventory->getVersionState($currentHeadVersionNum);
// Prepare the list of all files that should be in the new version
$filesForNewVersion = [];
$processedLogicalPaths = []; // To track files already handled (new/modified)
// Add new/modified files
foreach ($newFilesData as $file) {
$filesForNewVersion[] = $file;
$processedLogicalPaths[$file['path']] = true;
}
// Add retained files from the previous version, excluding deleted ones
foreach ($previousState as $digest => $logicalPaths) {
foreach ($logicalPaths as $logicalPath) {
// If the file is not marked for deletion and not already added as new/modified
if (!in_array($logicalPath, $deletedFiles) && !isset($processedLogicalPaths[$logicalPath])) {
// Fetch content from its physical location in the previous version
// This simulates the "copy on write" or "linking" behavior of OCFL.
$prevContentPath = "{$objectPath}/v{$currentHeadVersionNum}/" . OcflConstants::CONTENT_DIRECTORY . "/{$logicalPath}";
if ($this->filesystem->exists($prevContentPath)) {
$content = $this->filesystem->getContents($prevContentPath);
$filesForNewVersion[] = ['path' => $logicalPath, 'content' => $content];
} else {
// This case should ideally not happen if the previous OCFL object was valid.
// It indicates a potential corruption or a file that was meant to be linked
// but its source is missing. For now, we'll throw an error.
throw new OcflException("Retained file '{$logicalPath}' not found physically in previous version 'v{$currentHeadVersionNum}'.");
}
}
}
}
// Add the new version to the inventory
$inventory->addVersion($nextVersionNumber, $filesForNewVersion, $metadata, $this->filesystem, $objectPath, $previousState);
// Write the updated main inventory.json to the object root
$inventoryJsonPath = "{$objectPath}/" . OcflConstants::INVENTORY_FILENAME;
$this->filesystem->putContents($inventoryJsonPath, $inventory->toJson());
// Create the inventory digest file for the new version in its version directory
$this->filesystem->copy($inventoryJsonPath, "{$nextVersionDir}/" . OcflConstants::INVENTORY_FILENAME);
return $object;
}
/**
* Lists all OCFL object IDs found within the storage root.
*
* @return array An array of OCFL object IDs.
*/
public function listObjects(): array
{
$objectIds = [];
$contents = $this->filesystem->listContents($this->storageRoot);
foreach ($contents as $item) {
$itemPath = "{$this->storageRoot}/{$item}";
if ($this->filesystem->isDirectory($itemPath)) {
// Basic check to identify potential OCFL objects
if ($this->filesystem->exists("{$itemPath}/" . OcflConstants::INVENTORY_FILENAME) &&
$this->filesystem->exists("{$itemPath}/" . OcflConstants::OCFL_OBJECT_DECLARATION_FILENAME)) {
$objectIds[] = $item;
}
}
}
return $objectIds;
}
/**
* Helper to get the absolute path for an object ID within the storage root.
*
* @param string $objectId
* @return string
*/
private function getObjectPath(string $objectId): string
{
// OCFL allows for arbitrary object ID to path mappings.
// For simplicity, we use a direct mapping here: storage_root/object_id
return "{$this->storageRoot}/{$objectId}";
}
}

302
src/OcflStorageService.php

@ -0,0 +1,302 @@
<?php
namespace Drupal\ocfl_storage;
use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\node\NodeInterface;
use Drupal\file\FileInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
/**
* Service to handle OCFL-like storage operations.
*/
class OcflStorageService {
/**
* The file system service.
*
* @var \Drupal\Core\File\FileSystemInterface
*/
protected $fileSystem;
/**
* The logger factory.
*
* @var \Drupal\Core\Logger\LoggerChannelFactoryInterface
*/
protected $loggerFactory;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The OCFL root directory.
*
* @var string
*/
protected $ocflRoot;
/**
* Constructs an OcflStorageService object.
*
* @param \Drupal\Core\File\FileSystemInterface $file_system
* The file system service.
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
* The logger factory.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(FileSystemInterface $file_system, LoggerChannelFactoryInterface $logger_factory, EntityTypeManagerInterface $entity_type_manager) {
$this->fileSystem = $file_system;
$this->loggerFactory = $logger_factory;
$this->entityTypeManager = $entity_type_manager;
// Store OCFL objects in the private files directory.
$this->ocflRoot = $this->fileSystem->realpath('private://ocfl_root');
$this->fileSystem->prepareDirectory($this->ocflRoot, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
}
/**
* Saves a node's fields and attached files to the OCFL-like structure.
*
* @param \Drupal\node\NodeInterface $node
* The node to save.
*/
public function saveNodeToOcfl(NodeInterface $node) {
$nid = $node->id();
$object_id = 'node-' . $nid; // OCFL object ID based on node ID.
$object_path = $this->getObjectPath($object_id);
// Determine the next version number.
$current_version = $this->getCurrentVersion($object_id);
$next_version = $current_version + 1;
$version_path = $this->getVersionPath($object_id, $next_version);
$content_path = $this->fileSystem->mkdir($version_path . '/content',NULL,TRUE);
if (!$content_path) {
$this->loggerFactory->get('ocfl_storage')->error('Failed to create content directory for OCFL object @object_id version @version.', [
'@object_id' => $object_id,
'@version' => $next_version,
]);
return;
}
// Prepare metadata for inventory.json.
$metadata = [
'node_id' => $nid,
'node_type' => $node->getType(),
'title' => $node->label(),
'status' => $node->isPublished() ? 'published' : 'unpublished',
'changed' => $node->getChangedTime(),
'created' => $node->getCreatedTime(),
'author_uid' => $node->getOwnerId(),
'fields' => [],
'files' => [],
];
// Extract all field values.
foreach ($node->getFields() as $field_name => $field) {
// Skip internal fields that don't need to be in metadata, or handle them specially.
if (in_array($field_name, ['nid', 'uuid', 'vid', 'langcode', 'status', 'uid', 'created', 'changed', 'promote', 'sticky', 'revision_timestamp', 'revision_uid', 'revision_log'])) {
continue;
}
$field_value = [];
if (!$field->isEmpty()) {
foreach ($field as $item) {
$item_value = $item->getValue();
// Handle file fields specially.
if ($field->getFieldDefinition()->getType() === 'file' || $field->getFieldDefinition()->getType() === 'image') {
if (isset($item_value['target_id'])) {
$file_id = $item_value['target_id'];
$file = $this->entityTypeManager->getStorage('file')->load($file_id);
if ($file instanceof FileInterface) {
$original_uri = $file->getFileUri();
$filename = $this->fileSystem->basename($original_uri);
$destination = $content_path . '/' . $filename;
$this->fileSystem->copy($original_uri, $destination, FileSystemInterface::EXISTS_REPLACE);
$metadata['files'][] = [
'filename' => $filename,
'original_uri' => $original_uri,
'ocfl_path' => $this->fileSystem->uriScheme($destination) . '://' . $this->fileSystem->uriTarget($destination),
'mimetype' => $file->getMimeType(),
'size' => $file->getSize(),
];
// Store a reference to the OCFL path in the metadata, not the original URI.
$item_value['ocfl_uri'] = $this->fileSystem->uriScheme($destination) . '://' . $this->fileSystem->uriTarget($destination);
}
}
}
$field_value[] = $item_value;
}
}
$metadata['fields'][$field_name] = $field_value;
}
// Create inventory.json.
$inventory = [
'id' => $object_id,
'type' => 'OCFL_OBJECT', // Simplified type.
'head' => 'v' . $next_version,
'versions' => [
'v' . $next_version => [
'created' => \Drupal::time()->getRequestTime(),
'message' => 'Node ' . $node->label() . ' (NID: ' . $nid . ') version ' . $next_version,
'user' => [
'name' => \Drupal::currentUser()->getAccountName(),
'id' => \Drupal::currentUser()->id(),
],
'metadata' => $metadata, // Store node fields as metadata.
// In a real OCFL, this would be a manifest of files and their digests.
'content_files' => array_column($metadata['files'], 'filename'),
],
],
];
$inventory_json_path = $version_path . '/inventory.json';
if ($this->fileSystem->saveData(json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), $inventory_json_path)) {
$this->loggerFactory->get('ocfl_storage')->notice('Node %title (%nid) saved to OCFL object %object_id version %version.', [
'%title' => $node->label(),
'%nid' => $nid,
'%object_id' => $object_id,
'%version' => $next_version,
]);
} else {
$this->loggerFactory->get('ocfl_storage')->error('Failed to save inventory.json for OCFL object @object_id version @version.', [
'@object_id' => $object_id,
'@version' => $next_version,
]);
}
// Create the OCFL root indicator file if it doesn't exist.
$ocfl_root_indicator_path = $this->ocflRoot . '/.ocfl_layout';
$this->fileSystem->saveData('ocfl_layout_1.0', $ocfl_root_indicator_path,FileExists::Error);
}
/**
* Gets the base path for an OCFL object.
*
* @param string $object_id
* The OCFL object ID.
*
* @return string
* The absolute path to the object directory.
*/
public function getObjectPath(string $object_id): string {
return $this->ocflRoot . '/' . $object_id;
}
/**
* Gets the path for a specific version of an OCFL object.
*
* @param string $object_id
* The OCFL object ID.
* @param int $version
* The version number.
*
* @return string
* The absolute path to the version directory.
*/
public function getVersionPath(string $object_id, int $version): string {
return $this->getObjectPath($object_id) . '/v' . $version;
}
/**
* Gets the current (highest) version number for an OCFL object.
*
* @param string $object_id
* The OCFL object ID.
*
* @return int
* The current version number, or 0 if no versions exist.
*/
public function getCurrentVersion(string $object_id): int {
$object_path = $this->getObjectPath($object_id);
if (!is_dir($object_path) || !is_writable($object_path)) {
//if (!$this->fileSystem->isDirectory($object_path)) {
return 0;
}
$highest_version = 0;
$files = $this->fileSystem->scanDirectory($object_path, '/^v(\d+)$/', ['key' => 'name']);
foreach ($files as $file) {
if (preg_match('/^v(\d+)$/', $file->name, $matches)) {
$version = (int) $matches[1];
if ($version > $highest_version) {
$highest_version = $version;
}
}
}
return $highest_version;
}
/**
* Lists all OCFL object IDs.
*
* @return string[]
* An array of OCFL object IDs.
*/
public function listOcflObjects(): array {
$objects = [];
if (!is_dir($this->ocflRoot)) {
return $objects;
}
//$files = $this->fileSystem->scanDirectory($this->ocflRoot, '/^node-\d+$', ['key' => 'name']);
$files = scandir($this->ocflRoot);
foreach ($files as $file) {
if (is_dir($this->ocflRoot . '/' . $file) && $file[0] != '.') {
$objects[] = $file;
}
}
return $objects;
}
/**
* Gets details for a specific OCFL object.
*
* @param string $object_id
* The OCFL object ID.
*
* @return array|null
* An array of object details, or NULL if not found.
*/
public function getObjectDetails(string $object_id): ?array {
$object_path = $this->getObjectPath($object_id);
if (!is_dir($object_path)) {
return NULL;
}
$details = [
'id' => $object_id,
'path' => $object_path,
'versions' => [],
];
//$version_dirs = $this->fileSystem->scanDirectory($object_path, '/^v(\d+)$/', ['key' => 'name']);
$version_dirs = scandir($object_path);
foreach ($version_dirs as $version_dir) {
if ($version_dir != '.') {
$version_num = (int) substr($version_dir, 1);
$inventory_path = $this->getVersionPath($object_id, $version_num) . '/inventory.json';
if ($inventory_content = file_get_contents($inventory_path)) {
$inventory = json_decode($inventory_content, TRUE);
if ($inventory) {
$details['versions']['v' . $version_num] = $inventory['versions']['v' . $version_num] ?? [];
$details['versions']['v' . $version_num]['inventory_path'] = $inventory_path;
$details['versions']['v' . $version_num]['content_path'] = $this->getVersionPath($object_id, $version_num) . '/content';
}
}
}
}
ksort($details['versions']); // Sort versions numerically.
return $details;
}
}

30
src/ParamConverter/OcflObjectIdConverter.php

@ -0,0 +1,30 @@
<?php
namespace Drupal\ocfl_storage\ParamConverter;
use Drupal\Core\ParamConverter\ParamConverterInterface;
use Symfony\Component\Routing\Route;
/**
* Parameter converter for OCFL object IDs.
*/
class OcflObjectIdConverter implements ParamConverterInterface {
/**
* {@inheritdoc}
*/
public function convert($value, $definition, $name, array $defaults) {
// In a more complex scenario, you might load an actual OCFL object
// representation based on the ID here.
return $value;
}
/**
* {@inheritdoc}
*/
public function applies($definition, $name, Route $route) {
return (!empty($definition['type']) && $definition['type'] === 'ocfl_object_id');
}
}
Loading…
Cancel
Save