From 29020ffd98558dc05f97f5878d7fe3a2e2dae412 Mon Sep 17 00:00:00 2001 From: Paul Pound Date: Fri, 18 Jul 2025 14:53:58 -0300 Subject: [PATCH] first commit --- ocfl_storage.info.yml | 5 + ocfl_storage.module | 99 ++++++ ocfl_storage.routing.yml | 19 ++ ocfl_storage.services.yml | 4 + src/Controller/OcflStorageController.php | 189 +++++++++++ src/Ocfl/Exception/OcflException.php | 10 + .../Filesystem/FilesystemAdapterInterface.php | 124 +++++++ .../Filesystem/LocalFilesystemAdapter.php | 206 ++++++++++++ src/Ocfl/Model/OcflInventory.php | 197 ++++++++++++ src/Ocfl/Model/OcflObject.php | 109 +++++++ src/Ocfl/OcflConstants.php | 15 + src/Ocfl/OcflObjectManager.php | 223 +++++++++++++ src/OcflStorageService.php | 302 ++++++++++++++++++ src/ParamConverter/OcflObjectIdConverter.php | 30 ++ 14 files changed, 1532 insertions(+) create mode 100644 ocfl_storage.info.yml create mode 100644 ocfl_storage.module create mode 100644 ocfl_storage.routing.yml create mode 100644 ocfl_storage.services.yml create mode 100644 src/Controller/OcflStorageController.php create mode 100644 src/Ocfl/Exception/OcflException.php create mode 100644 src/Ocfl/Filesystem/FilesystemAdapterInterface.php create mode 100644 src/Ocfl/Filesystem/LocalFilesystemAdapter.php create mode 100644 src/Ocfl/Model/OcflInventory.php create mode 100644 src/Ocfl/Model/OcflObject.php create mode 100644 src/Ocfl/OcflConstants.php create mode 100644 src/Ocfl/OcflObjectManager.php create mode 100644 src/OcflStorageService.php create mode 100644 src/ParamConverter/OcflObjectIdConverter.php diff --git a/ocfl_storage.info.yml b/ocfl_storage.info.yml new file mode 100644 index 0000000..9383b6f --- /dev/null +++ b/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 diff --git a/ocfl_storage.module b/ocfl_storage.module new file mode 100644 index 0000000..b2d2582 --- /dev/null +++ b/ocfl_storage.module @@ -0,0 +1,99 @@ +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 = '

' . t('About') . '

'; + $output .= '

' . 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.') . '

'; + $output .= '

' . t('Usage') . '

'; + $output .= '

' . 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.') . '

'; + return $output; + + case 'ocfl_storage.browse': + return '

' . t('This page lists all OCFL-like objects currently stored by the module.') . '

'; + + case 'ocfl_storage.object_view': + return '

' . t('Details for a specific OCFL-like object, including its versions and contents.') . '

'; + } +} + +/** + * 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'); + } +} diff --git a/ocfl_storage.routing.yml b/ocfl_storage.routing.yml new file mode 100644 index 0000000..b0efbd7 --- /dev/null +++ b/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 diff --git a/ocfl_storage.services.yml b/ocfl_storage.services.yml new file mode 100644 index 0000000..1f7be21 --- /dev/null +++ b/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'] diff --git a/src/Controller/OcflStorageController.php b/src/Controller/OcflStorageController.php new file mode 100644 index 0000000..f084204 --- /dev/null +++ b/src/Controller/OcflStorageController.php @@ -0,0 +1,189 @@ +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' => '

' . $this->t('Details for OCFL Object: @id', ['@id' => $object_id]) . '

', + ]; + + 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('Message: @message
User: @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; + } + +} diff --git a/src/Ocfl/Exception/OcflException.php b/src/Ocfl/Exception/OcflException.php new file mode 100644 index 0000000..d83aae5 --- /dev/null +++ b/src/Ocfl/Exception/OcflException.php @@ -0,0 +1,10 @@ +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; + } +} diff --git a/src/Ocfl/Model/OcflInventory.php b/src/Ocfl/Model/OcflInventory.php new file mode 100644 index 0000000..b384134 --- /dev/null +++ b/src/Ocfl/Model/OcflInventory.php @@ -0,0 +1,197 @@ + { 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; + } +} diff --git a/src/Ocfl/Model/OcflObject.php b/src/Ocfl/Model/OcflObject.php new file mode 100644 index 0000000..db0d88e --- /dev/null +++ b/src/Ocfl/Model/OcflObject.php @@ -0,0 +1,109 @@ +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); + } +} diff --git a/src/Ocfl/OcflConstants.php b/src/Ocfl/OcflConstants.php new file mode 100644 index 0000000..3af2482 --- /dev/null +++ b/src/Ocfl/OcflConstants.php @@ -0,0 +1,15 @@ +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}"; + } +} diff --git a/src/OcflStorageService.php b/src/OcflStorageService.php new file mode 100644 index 0000000..7835d0b --- /dev/null +++ b/src/OcflStorageService.php @@ -0,0 +1,302 @@ +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; + } + +} diff --git a/src/ParamConverter/OcflObjectIdConverter.php b/src/ParamConverter/OcflObjectIdConverter.php new file mode 100644 index 0000000..6099e3b --- /dev/null +++ b/src/ParamConverter/OcflObjectIdConverter.php @@ -0,0 +1,30 @@ +