commit
29020ffd98
14 changed files with 1532 additions and 0 deletions
@ -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 |
||||
@ -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'); |
||||
} |
||||
} |
||||
@ -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 |
||||
@ -0,0 +1,4 @@
|
||||
services: |
||||
ocfl_storage.ocfl_service: |
||||
class: Drupal\ocfl_storage\OcflStorageService |
||||
arguments: ['@file_system', '@logger.factory', '@entity_type.manager'] |
||||
@ -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; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,10 @@
|
||||
<?php |
||||
|
||||
namespace Ocfl\Exception; |
||||
|
||||
/** |
||||
* Base exception for OCFL library errors. |
||||
*/ |
||||
class OcflException extends \Exception |
||||
{ |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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'; |
||||
} |
||||
@ -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}"; |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
|
||||
} |
||||
@ -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…
Reference in new issue