diff --git a/appinfo/info.xml b/appinfo/info.xml old mode 100755 new mode 100644 diff --git a/appinfo/routes.php b/appinfo/routes.php index 63b8637..11bef22 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -14,11 +14,15 @@ ['name' => 'objects#mappings', 'url' => '/api/objects/mappings', 'verb' => 'GET'], ['name' => 'objects#auditTrails', 'url' => '/api/objects/audit-trails/{id}', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], ['name' => 'objects#relations', 'url' => '/api/objects/relations/{id}', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], + ['name' => 'objects#files', 'url' => '/api/objects/files/{id}', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], ['name' => 'schemas#upload', 'url' => '/api/schemas/upload', 'verb' => 'POST'], ['name' => 'schemas#uploadUpdate', 'url' => '/api/schemas/{id}/upload', 'verb' => 'PUT', 'requirements' => ['id' => '[^/]+']], ['name' => 'schemas#download', 'url' => '/api/schemas/{id}/download', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], ['name' => 'registers#upload', 'url' => '/api/registers/upload', 'verb' => 'POST'], ['name' => 'registers#uploadUpdate', 'url' => '/api/registers/{id}/upload', 'verb' => 'PUT', 'requirements' => ['id' => '[^/]+']], ['name' => 'registers#download', 'url' => '/api/registers/{id}/download', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], + ['name' => 'objects#lock', 'url' => '/api/objects/{id}/lock', 'verb' => 'POST'], + ['name' => 'objects#unlock', 'url' => '/api/objects/{id}/unlock', 'verb' => 'POST'], + ['name' => 'objects#revert', 'url' => '/api/objects/{id}/revert', 'verb' => 'POST'], ], ]; diff --git a/docs/object/object.md b/docs/object/object.md new file mode 100644 index 0000000..dcc429a --- /dev/null +++ b/docs/object/object.md @@ -0,0 +1,98 @@ +# Objects in OpenRegister + +Objects are the core data entities in OpenRegister that store and manage structured information. This document explains everything you need to know about working with objects. + +## Overview + +An object in OpenRegister represents a single record of data that: +- Conforms to a defined schema +- Belongs to a specific register +- Has a unique UUID identifier +- Can contain nested objects and file attachments +- Maintains version history through audit logs +- Can be linked to other objects via relations + +## Object Structure + +Each object contains: + +- `id`: Unique UUID identifier +- `uri`: Absolute URL to access the object +- `version`: Semantic version number (e.g. 1.0.0) +- `register`: The register this object belongs to +- `schema`: The schema this object must conform to +- `object`: The actual data payload as JSON +- `files`: Array of related file IDs +- `relations`: Array of related object IDs +- `textRepresentation`: Text representation of the object +- `locked`: Lock status and details +- `owner`: Nextcloud user that owns the object +- `authorization`: JSON object describing access permissions +- `updated`: Last modification timestamp +- `created`: Creation timestamp + +## Key Features + +### Schema Validation +- Objects are validated against their schema definition +- Supports both soft and hard validation modes +- Ensures data integrity and consistency + +### Relations & Nesting +- Objects can reference other objects via UUIDs or URLs +- Supports nested object structures up to configured depth +- Maintains bidirectional relationship tracking + +### Version Control +- Automatic version incrementing +- Full audit trail of changes +- Historical version access +- Ability to revert to any previous version +- Detailed change tracking between versions + +### Access Control +- Object-level ownership +- Granular authorization rules +- Lock mechanism for concurrent access control + +### File Attachments +- Support for file attachments +- Secure file storage integration +- File metadata tracking + +## API Operations + +Objects support standard CRUD operations: +- Create new objects +- Read existing objects +- Update object data +- Delete objects +- Search and filter objects +- Export object data +- Revert to previous versions + +## Object Locking and Versioning + +### Version Control and Revert Functionality + +OpenRegister provides comprehensive version control capabilities: + +- Every change creates a new version with full audit trail +- Changes are tracked at the field level +- Previous versions can be viewed and compared +- Objects can be reverted to any historical version +- Revert operation creates new version rather than overwriting +- Audit logs maintain full history of all changes including reverts +- Revert includes all object properties including relations and files +- Batch revert operations supported for related objects + +### Locking Objects + +Objects can be locked to prevent concurrent modifications. This is useful when: +- Long-running processes need exclusive access +- Multiple users/systems are working on the same object +- Ensuring data consistency during complex updates + +#### Lock API Endpoints + +To lock an object via the REST API: \ No newline at end of file diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php index 6556d33..45e6f05 100644 --- a/lib/Controller/ObjectsController.php +++ b/lib/Controller/ObjectsController.php @@ -40,7 +40,9 @@ public function __construct( private readonly ContainerInterface $container, private readonly ObjectEntityMapper $objectEntityMapper, private readonly AuditTrailMapper $auditTrailMapper, - private readonly ObjectAuditLogMapper $objectAuditLogMapper + private readonly ObjectAuditLogMapper $objectAuditLogMapper, + private readonly ObjectService $objectService, + ) { parent::__construct($appName, $request); @@ -86,7 +88,8 @@ public function index(ObjectService $objectService, SearchService $searchService $filters = $searchService->unsetSpecialQueryParams(filters: $filters); // @todo: figure out how to use extend here - $results = $this->objectEntityMapper->findAll(filters: $filters); + $results = $objectService->getObjects(objectType: 'objectEntity', filters: $filters); +// $results = $this->objectEntityMapper->findAll(filters: $filters); // We dont want to return the entity, but the object (and kant reley on the normal serilzier) foreach ($results as $key => $result) { @@ -160,6 +163,13 @@ public function create(ObjectService $objectService): JSONResponse // Save the object try { $objectEntity = $objectService->saveObject(register: $data['register'], schema: $data['schema'], object: $object); + + // Unlock the object after saving + try { + $this->objectEntityMapper->unlockObject($objectEntity->getId()); + } catch (\Exception $e) { + // Ignore unlock errors since the save was successful + } } catch (ValidationException $exception) { $formatter = new ErrorFormatter(); return new JSONResponse(['message' => $exception->getMessage(), 'validationErrors' => $formatter->format($exception->getErrors())], 400); @@ -206,6 +216,13 @@ public function update(int $id, ObjectService $objectService): JSONResponse // save it try { $objectEntity = $objectService->saveObject(register: $data['register'], schema: $data['schema'], object: $data['object']); + + // Unlock the object after saving + try { + $this->objectEntityMapper->unlockObject($objectEntity->getId()); + } catch (\Exception $e) { + // Ignore unlock errors since the save was successful + } } catch (ValidationException $exception) { $formatter = new ErrorFormatter(); return new JSONResponse(['message' => $exception->getMessage(), 'validationErrors' => $formatter->format($exception->getErrors())], 400); @@ -328,8 +345,6 @@ public function logs(int $id): JSONResponse } } - - /** * Retrieves all available mappings * @@ -380,4 +395,182 @@ public function getOpenConnectorMappingService(): ?\OCA\OpenConnector\Service\Ma return null; } + + /** + * Lock an object + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param int $id The ID of the object to lock + * @return JSONResponse A JSON response containing the locked object + */ + public function lock(int $id): JSONResponse + { + try { + $data = $this->request->getParams(); + $process = $data['process'] ?? null; + $duration = isset($data['duration']) ? (int)$data['duration'] : null; + + $object = $this->objectEntityMapper->lockObject( + $id, + $process, + $duration + ); + + return new JSONResponse($object->getObjectArray()); + + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Object not found'], 404); + } catch (NotAuthorizedException $e) { + return new JSONResponse(['error' => $e->getMessage()], 403); + } catch (LockedException $e) { + return new JSONResponse(['error' => $e->getMessage()], 423); // 423 Locked + } catch (\Exception $e) { + return new JSONResponse(['error' => $e->getMessage()], 500); + } + } + + /** + * Unlock an object + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param int $id The ID of the object to unlock + * @return JSONResponse A JSON response containing the unlocked object + */ + public function unlock(int $id): JSONResponse + { + try { + $object = $this->objectEntityMapper->unlockObject($id); + return new JSONResponse($object->getObjectArray()); + + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Object not found'], 404); + } catch (NotAuthorizedException $e) { + return new JSONResponse(['error' => $e->getMessage()], 403); + } catch (LockedException $e) { + return new JSONResponse(['error' => $e->getMessage()], 423); + } catch (\Exception $e) { + return new JSONResponse(['error' => $e->getMessage()], 500); + } + } + + /** + * Revert an object to a previous state + * + * This endpoint allows reverting an object to a previous state based on different criteria: + * 1. DateTime - Revert to the state at a specific point in time + * 2. Audit Trail ID - Revert to the state after a specific audit trail entry + * 3. Semantic Version - Revert to a specific version of the object + * + * Request body should contain one of: + * - datetime: ISO 8601 datetime string (e.g., "2024-03-01T12:00:00Z") + * - auditTrailId: UUID of an audit trail entry + * - version: Semantic version string (e.g., "1.0.0") + * + * Optional parameters: + * - overwriteVersion: boolean (default: false) - If true, keeps the version number, + * if false, increments the patch version + * + * Example requests: + * ```json + * { + * "datetime": "2024-03-01T12:00:00Z" + * } + * ``` + * ```json + * { + * "auditTrailId": "550e8400-e29b-41d4-a716-446655440000" + * } + * ``` + * ```json + * { + * "version": "1.0.0", + * "overwriteVersion": true + * } + * ``` + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param int $id The ID of the object to revert + * @return JSONResponse A JSON response containing the reverted object + * @throws NotFoundException If object not found + * @throws NotAuthorizedException If user not authorized + * @throws BadRequestException If no valid reversion point specified + * @throws LockedException If object is locked + */ + public function revert(int $id): JSONResponse + { + try { + $data = $this->request->getParams(); + + // Parse the revert point + $until = null; + if (isset($data['datetime'])) { + $until = new \DateTime($data['datetime']); + } elseif (isset($data['auditTrailId'])) { + $until = $data['auditTrailId']; + } elseif (isset($data['version'])) { + $until = $data['version']; + } + + if ($until === null) { + return new JSONResponse( + ['error' => 'Must specify either datetime, auditTrailId, or version'], + 400 + ); + } + + $overwriteVersion = $data['overwriteVersion'] ?? false; + + // Get the reverted object using AuditTrailMapper instead + $revertedObject = $this->auditTrailMapper->revertObject( + $id, + $until, + $overwriteVersion + ); + + // Save the reverted object + $savedObject = $this->objectEntityMapper->update($revertedObject); + + return new JSONResponse($savedObject->getObjectArray()); + + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Object not found'], 404); + } catch (NotAuthorizedException $e) { + return new JSONResponse(['error' => $e->getMessage()], 403); + } catch (\Exception $e) { + return new JSONResponse(['error' => $e->getMessage()], 500); + } + } + + /** + * Retrieves files associated with an object + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $id The ID of the object to get files for + * @return JSONResponse A JSON response containing the object's files + */ + public function files(string $id, ObjectService $objectService): JSONResponse + { + try { + // Get the object with files included + $object = $this->objectEntityMapper->find((int) $id); + $files = $objectService->getFiles($object); + $object = $objectService->hydrateFiles($object, $files); + + // Return just the files array from the object + return new JSONResponse($object->getFiles()); + + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Object not found'], 404); + } catch (\Exception $e) { + return new JSONResponse(['error' => $e->getMessage()], 500); + } + } } diff --git a/lib/Db/AuditTrailMapper.php b/lib/Db/AuditTrailMapper.php index 974351e..d327568 100644 --- a/lib/Db/AuditTrailMapper.php +++ b/lib/Db/AuditTrailMapper.php @@ -11,13 +11,13 @@ use OCA\OpenRegister\Db\ObjectEntityMapper; /** - * The AuditTrailMapper class + * The AuditTrailMapper class handles audit trail operations and object reversions * * @package OCA\OpenRegister\Db */ class AuditTrailMapper extends QBMapper { - private $objectEntityMapper; + private ObjectEntityMapper $objectEntityMapper; /** * Constructor for the AuditTrailMapper @@ -198,5 +198,137 @@ public function createAuditTrail(?ObjectEntity $old = null, ?ObjectEntity $new = return $this->insert(entity: $auditTrail); } + /** + * Get audit trails for an object until a specific point or version + * + * @param int $objectId The object ID + * @param string $objectUuid The object UUID + * @param DateTime|string|null $until DateTime, AuditTrail ID, or semantic version to get trails until + * @return array Array of AuditTrail objects + */ + public function findByObjectUntil(int $objectId, string $objectUuid, $until = null): array + { + $qb = $this->db->getQueryBuilder(); + + // Base query + $qb->select('*') + ->from('openregister_audit_trails') + ->where( + $qb->expr()->eq('object_id', $qb->createNamedParameter($objectId, IQueryBuilder::PARAM_INT)) + ) + ->andWhere( + $qb->expr()->eq('object_uuid', $qb->createNamedParameter($objectUuid, IQueryBuilder::PARAM_STR)) + ) + ->orderBy('created', 'DESC'); + + // Add condition based on until parameter + if ($until instanceof \DateTime) { + $qb->andWhere( + $qb->expr()->gte('created', $qb->createNamedParameter( + $until->format('Y-m-d H:i:s'), + IQueryBuilder::PARAM_STR + )) + ); + } elseif (is_string($until)) { + if ($this->isSemanticVersion($until)) { + // Handle semantic version + $qb->andWhere( + $qb->expr()->eq('version', $qb->createNamedParameter($until, IQueryBuilder::PARAM_STR)) + ); + } else { + // Handle audit trail ID + $qb->andWhere( + $qb->expr()->eq('id', $qb->createNamedParameter($until, IQueryBuilder::PARAM_STR)) + ); + // We want all entries up to and including this ID + $qb->orWhere( + $qb->expr()->gt('created', + $qb->createFunction('(SELECT created FROM `*PREFIX*openregister_audit_trails` WHERE id = ' . + $qb->createNamedParameter($until, IQueryBuilder::PARAM_STR) . ')') + ) + ); + } + } + + return $this->findEntities($qb); + } + + /** + * Check if a string is a semantic version + * + * @param string $version The version string to check + * @return bool True if string is a semantic version + */ + private function isSemanticVersion(string $version): bool + { + return preg_match('/^\d+\.\d+\.\d+$/', $version) === 1; + } + + /** + * Revert an object to a previous state + * + * @param string|int $identifier Object ID, UUID, or URI + * @param DateTime|string|null $until DateTime or AuditTrail ID to revert to + * @param bool $overwriteVersion Whether to overwrite the version or increment it + * @return ObjectEntity The reverted object (unsaved) + * @throws DoesNotExistException If object not found + * @throws \Exception If revert fails + */ + public function revertObject($identifier, $until = null, bool $overwriteVersion = false): ObjectEntity + { + // Get the current object + $object = $this->objectEntityMapper->find($identifier); + + // Get audit trail entries until the specified point + $auditTrails = $this->findByObjectUntil( + $object->getId(), + $object->getUuid(), + $until + ); + + if (empty($auditTrails) && $until !== null) { + throw new \Exception('No audit trail entries found for the specified reversion point'); + } + + // Create a clone of the current object to apply reversions + $revertedObject = clone $object; + + // Apply changes in reverse + foreach ($auditTrails as $audit) { + $this->revertChanges($revertedObject, $audit); + } + + // Handle versioning + if (!$overwriteVersion) { + $version = explode('.', $revertedObject->getVersion()); + $version[2] = (int) $version[2] + 1; + $revertedObject->setVersion(implode('.', $version)); + } + + return $revertedObject; + } + + /** + * Helper function to revert changes from an audit trail entry + * + * @param ObjectEntity $object The object to apply reversions to + * @param AuditTrail $audit The audit trail entry + */ + private function revertChanges(ObjectEntity $object, AuditTrail $audit): void + { + $changes = $audit->getChanges(); + + // Iterate through each change and apply the reverse + foreach ($changes as $field => $change) { + if (isset($change['old'])) { + // Use reflection to set the value if it's a protected property + $reflection = new \ReflectionClass($object); + $property = $reflection->getProperty($field); + $property->setAccessible(true); + $property->setValue($object, $change['old']); + } + } + } + // We dont need update as we dont change the log } diff --git a/lib/Db/ObjectAuditLogMapper.php b/lib/Db/ObjectAuditLogMapper.php index 387fb6f..4f89a54 100644 --- a/lib/Db/ObjectAuditLogMapper.php +++ b/lib/Db/ObjectAuditLogMapper.php @@ -85,4 +85,51 @@ public function updateFromArray(int $id, array $object): ObjectAuditLog return $this->update($obj); } + + /** + * Get audit trails for an object until a specific point + * + * @param int $objectId The object ID + * @param string $objectUuid The object UUID + * @param DateTime|string|null $until DateTime or AuditTrail ID to get trails until + * @return array Array of AuditTrail objects + */ + public function findByObjectUntil(int $objectId, string $objectUuid, $until = null): array + { + $qb = $this->db->getQueryBuilder(); + + // Base query + $qb->select('*') + ->from('openregister_auditlog') + ->where( + $qb->expr()->eq('object_id', $qb->createNamedParameter($objectId, IQueryBuilder::PARAM_INT)) + ) + ->andWhere( + $qb->expr()->eq('object_uuid', $qb->createNamedParameter($objectUuid, IQueryBuilder::PARAM_STR)) + ) + ->orderBy('created', 'DESC'); + + // Add condition based on until parameter + if ($until instanceof \DateTime) { + $qb->andWhere( + $qb->expr()->gte('created', $qb->createNamedParameter( + $until->format('Y-m-d H:i:s'), + IQueryBuilder::PARAM_STR + )) + ); + } elseif (is_string($until)) { + $qb->andWhere( + $qb->expr()->eq('id', $qb->createNamedParameter($until, IQueryBuilder::PARAM_STR)) + ); + // We want all entries up to and including this ID + $qb->orWhere( + $qb->expr()->gt('created', + $qb->createFunction('(SELECT created FROM `*PREFIX*openregister_auditlog` WHERE id = ' . + $qb->createNamedParameter($until, IQueryBuilder::PARAM_STR) . ')') + ) + ); + } + + return $this->findEntities($qb); + } } diff --git a/lib/Db/ObjectEntity.php b/lib/Db/ObjectEntity.php index dc73772..3d5e8ab 100644 --- a/lib/Db/ObjectEntity.php +++ b/lib/Db/ObjectEntity.php @@ -5,6 +5,7 @@ use DateTime; use JsonSerializable; use OCP\AppFramework\Db\Entity; +use OCP\IUserSession; class ObjectEntity extends Entity implements JsonSerializable { @@ -17,8 +18,12 @@ class ObjectEntity extends Entity implements JsonSerializable protected ?array $files = []; // array of file ids that are related to this object protected ?array $relations = []; // array of object ids that this object is related to protected ?string $textRepresentation = null; + protected ?array $locked = null; // Contains the locked object if the object is locked + protected ?string $owner = null; // The Nextcloud user that owns this object + protected ?array $authorization = []; // JSON object describing authorizations protected ?DateTime $updated = null; protected ?DateTime $created = null; + protected ?string $folder = null; // The folder path where this object is stored public function __construct() { $this->addType(fieldName:'uuid', type: 'string'); @@ -30,8 +35,12 @@ public function __construct() { $this->addType(fieldName:'files', type: 'json'); $this->addType(fieldName:'relations', type: 'json'); $this->addType(fieldName:'textRepresentation', type: 'text'); + $this->addType(fieldName:'locked', type: 'json'); + $this->addType(fieldName:'owner', type: 'string'); + $this->addType(fieldName:'authorization', type: 'json'); $this->addType(fieldName:'updated', type: 'datetime'); $this->addType(fieldName:'created', type: 'datetime'); + $this->addType(fieldName:'folder', type: 'string'); } public function getJsonFields(): array @@ -86,9 +95,130 @@ public function getObjectArray(): array 'files' => $this->files, 'relations' => $this->relations, 'textRepresentation' => $this->textRepresentation, + 'locked' => $this->locked, + 'owner' => $this->owner, + 'authorization' => $this->authorization, 'updated' => isset($this->updated) ? $this->updated->format('c') : null, - 'created' => isset($this->created) ? $this->created->format('c') : null + 'created' => isset($this->created) ? $this->created->format('c') : null, + 'folder' => $this->folder ]; } + /** + * Lock the object for a specific duration + * + * @param IUserSession $userSession Current user session + * @param string|null $process Optional process identifier + * @param int|null $duration Lock duration in seconds (default: 1 hour) + * @return bool True if lock was successful + * @throws \Exception If object is already locked by another user + */ + public function lock(IUserSession $userSession, ?string $process = null, ?int $duration = 3600): bool + { + $currentUser = $userSession->getUser(); + if (!$currentUser) { + throw new \Exception('No user logged in'); + } + + $userId = $currentUser->getUID(); + $now = new \DateTime(); + + // If already locked, check if it's the same user and not expired + if ($this->isLocked()) { + $lock = $this->locked; + + // If locked by different user + if ($lock['user'] !== $userId) { + throw new \Exception('Object is locked by another user'); + } + + // If same user, extend the lock + $expirationDate = new \DateTime($lock['expiration']); + $newExpiration = clone $now; + $newExpiration->add(new \DateInterval('PT' . $duration . 'S')); + + $this->locked = [ + 'user' => $userId, + 'process' => $process ?? $lock['process'], + 'created' => $lock['created'], + 'duration' => $duration, + 'expiration' => $newExpiration->format('c') + ]; + } else { + // Create new lock + $expiration = clone $now; + $expiration->add(new \DateInterval('PT' . $duration . 'S')); + + $this->locked = [ + 'user' => $userId, + 'process' => $process, + 'created' => $now->format('c'), + 'duration' => $duration, + 'expiration' => $expiration->format('c') + ]; + } + + return true; + } + + /** + * Unlock the object + * + * @param IUserSession $userSession Current user session + * @return bool True if unlock was successful + * @throws \Exception If object is locked by another user + */ + public function unlock(IUserSession $userSession): bool + { + if (!$this->isLocked()) { + return true; + } + + $currentUser = $userSession->getUser(); + if (!$currentUser) { + throw new \Exception('No user logged in'); + } + + $userId = $currentUser->getUID(); + + // Check if locked by different user + if ($this->locked['user'] !== $userId) { + throw new \Exception('Object is locked by another user'); + } + + $this->locked = null; + return true; + } + + /** + * Check if the object is currently locked + * + * @return bool True if object is locked and lock hasn't expired + */ + public function isLocked(): bool + { + if (!$this->locked) { + return false; + } + + // Check if lock has expired + $now = new \DateTime(); + $expiration = new \DateTime($this->locked['expiration']); + + return $now < $expiration; + } + + /** + * Get lock information + * + * @return array|null Lock information or null if not locked + */ + public function getLockInfo(): ?array + { + if (!$this->isLocked()) { + return null; + } + + return $this->locked; + } } diff --git a/lib/Db/ObjectEntityMapper.php b/lib/Db/ObjectEntityMapper.php index b62bc2a..8a82cb6 100644 --- a/lib/Db/ObjectEntityMapper.php +++ b/lib/Db/ObjectEntityMapper.php @@ -12,11 +12,15 @@ use OCP\AppFramework\Db\QBMapper; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; +use OCP\IUserSession; use Symfony\Component\Uid\Uuid; use OCP\EventDispatcher\IEventDispatcher; use OCA\OpenRegister\Event\ObjectCreatedEvent; use OCA\OpenRegister\Event\ObjectUpdatedEvent; use OCA\OpenRegister\Event\ObjectDeletedEvent; +use OCA\OpenRegister\Event\ObjectLockedEvent; +use OCA\OpenRegister\Event\ObjectUnlockedEvent; +use OCA\OpenRegister\Db\AuditTrailMapper; /** * The ObjectEntityMapper class @@ -27,8 +31,10 @@ class ObjectEntityMapper extends QBMapper { private IDatabaseJsonService $databaseJsonService; private IEventDispatcher $eventDispatcher; + private IUserSession $userSession; public const MAIN_FILTERS = ['register', 'schema', 'uuid', 'created', 'updated']; + public const DEFAULT_LOCK_DURATION = 3600; /** * Constructor for the ObjectEntityMapper @@ -36,11 +42,13 @@ class ObjectEntityMapper extends QBMapper * @param IDBConnection $db The database connection * @param MySQLJsonService $mySQLJsonService The MySQL JSON service * @param IEventDispatcher $eventDispatcher The event dispatcher + * @param IUserSession $userSession The user session */ public function __construct( - IDBConnection $db, + IDBConnection $db, MySQLJsonService $mySQLJsonService, - IEventDispatcher $eventDispatcher + IEventDispatcher $eventDispatcher, + IUserSession $userSession, ) { parent::__construct($db, 'openregister_objects'); @@ -48,6 +56,7 @@ public function __construct( $this->databaseJsonService = $mySQLJsonService; } $this->eventDispatcher = $eventDispatcher; + $this->userSession = $userSession; } /** @@ -274,11 +283,11 @@ public function createFromArray(array $object): ObjectEntity if ($obj->getUuid() === null) { $obj->setUuid(Uuid::v4()); } - - $obj = $this->insert($obj); - - - return $obj; + // Set current user as owner when creating new object + if ($this->userSession->isLoggedIn()) { + $obj->setOwner($this->userSession->getUser()->getUID()); + } + return $this->insert($obj); } /** @@ -316,7 +325,10 @@ public function updateFromArray(int $id, array $object): ObjectEntity $newObject->setVersion(implode('.', $version)); } - $newObject = $this->update($newObject); + // Set current user as owner if not already set + if ($obj->getOwner() === null && $this->userSession->isLoggedIn()) { + $obj->setOwner($this->userSession->getUser()->getUID()); + } return $newObject; } @@ -410,4 +422,87 @@ public function findByRelationUri(string $search, bool $partialMatch = false): a return $this->findEntities($qb); } + + /** + * Lock an object + * + * @param string|int $identifier Object ID, UUID, or URI + * @param string|null $process Optional process identifier + * @param int|null $duration Lock duration in seconds + * @return ObjectEntity The locked object + * @throws DoesNotExistException If object not found + * @throws \Exception If locking fails + */ + public function lockObject($identifier, ?string $process = null, ?int $duration = null): ObjectEntity + { + $object = $this->find($identifier); + + if ($duration === null) { + $duration = $this::DEFAULT_LOCK_DURATION; + } + + // Check if user has permission to lock + if (!$this->userSession->isLoggedIn()) { + throw new \Exception('Must be logged in to lock objects'); + } + + // Attempt to lock the object + $object->lock($this->userSession, $process, $duration); + + // Save the locked object + $object = $this->update($object); + + // Dispatch lock event + $this->eventDispatcher->dispatch( + ObjectLockedEvent::class, + new ObjectLockedEvent($object) + ); + + return $object; + } + + /** + * Unlock an object + * + * @param string|int $identifier Object ID, UUID, or URI + * @return ObjectEntity The unlocked object + * @throws DoesNotExistException If object not found + * @throws \Exception If unlocking fails + */ + public function unlockObject($identifier): ObjectEntity + { + $object = $this->find($identifier); + + // Check if user has permission to unlock + if (!$this->userSession->isLoggedIn()) { + throw new \Exception('Must be logged in to unlock objects'); + } + + // Attempt to unlock the object + $object->unlock($this->userSession); + + // Save the unlocked object + $object = $this->update($object); + + // Dispatch unlock event + $this->eventDispatcher->dispatch( + ObjectUnlockedEvent::class, + new ObjectUnlockedEvent($object) + ); + + return $object; + } + + /** + * Check if an object is locked + * + * @param string|int $identifier Object ID, UUID, or URI + * @return bool True if object is locked, false otherwise + * @throws DoesNotExistException If object not found + */ + public function isObjectLocked($identifier): bool + { + $object = $this->find($identifier); + return $object->isLocked(); + } } diff --git a/lib/Db/Register.php b/lib/Db/Register.php index 0c03d93..df3bab6 100644 --- a/lib/Db/Register.php +++ b/lib/Db/Register.php @@ -6,6 +6,20 @@ use JsonSerializable; use OCP\AppFramework\Db\Entity; +/** + * Entity class representing a Register + * + * @property string|null $uuid Unique identifier for the register + * @property string|null $title Title of the register + * @property string|null $version Version of the register + * @property string|null $description Description of the register + * @property array|null $schemas Schemas associated with the register + * @property string|null $source Source of the register + * @property string|null $tablePrefix Prefix for database tables + * @property string|null $folder Nextcloud folder path where register is stored + * @property DateTime|null $updated Last update timestamp + * @property DateTime|null $created Creation timestamp + */ class Register extends Entity implements JsonSerializable { protected ?string $uuid = null; @@ -15,6 +29,7 @@ class Register extends Entity implements JsonSerializable protected ?array $schemas = []; protected ?string $source = null; protected ?string $tablePrefix = null; + protected ?string $folder = null; // Nextcloud folder path where register is stored protected ?DateTime $updated = null; protected ?DateTime $created = null; @@ -27,6 +42,7 @@ public function __construct() { $this->addType(fieldName: 'schemas', type: 'json'); $this->addType(fieldName: 'source', type: 'string'); $this->addType(fieldName: 'tablePrefix', type: 'string'); + $this->addType(fieldName: 'folder', type: 'string'); $this->addType(fieldName: 'updated', type: 'datetime'); $this->addType(fieldName: 'created', type: 'datetime'); } @@ -76,6 +92,7 @@ public function jsonSerialize(): array 'schemas' => $this->schemas, 'source' => $this->source, 'tablePrefix' => $this->tablePrefix, + 'folder' => $this->folder, 'updated' => isset($this->updated) ? $this->updated->format('c') : null, 'created' => isset($this->created) ? $this->created->format('c') : null ]; diff --git a/lib/Event/ObjectLockedEvent.php b/lib/Event/ObjectLockedEvent.php new file mode 100644 index 0000000..1fe97ab --- /dev/null +++ b/lib/Event/ObjectLockedEvent.php @@ -0,0 +1,22 @@ +object = $object; + } + + public function getObject(): ObjectEntity { + return $this->object; + } +} \ No newline at end of file diff --git a/lib/Event/ObjectRevertedEvent.php b/lib/Event/ObjectRevertedEvent.php new file mode 100644 index 0000000..5986603 --- /dev/null +++ b/lib/Event/ObjectRevertedEvent.php @@ -0,0 +1,32 @@ +object = $object; + $this->until = $until; + } + + public function getObject(): ObjectEntity { + return $this->object; + } + + public function getRevertPoint() { + return $this->until; + } +} \ No newline at end of file diff --git a/lib/Event/ObjectUnlockedEvent.php b/lib/Event/ObjectUnlockedEvent.php new file mode 100644 index 0000000..c8c7bc4 --- /dev/null +++ b/lib/Event/ObjectUnlockedEvent.php @@ -0,0 +1,22 @@ +object = $object; + } + + public function getObject(): ObjectEntity { + return $this->object; + } +} \ No newline at end of file diff --git a/lib/EventListener/AbstractNodeFolderEventListener.php b/lib/EventListener/AbstractNodeFolderEventListener.php new file mode 100644 index 0000000..05f93e1 --- /dev/null +++ b/lib/EventListener/AbstractNodeFolderEventListener.php @@ -0,0 +1,62 @@ +getNode(); + if ($node->getType() !== FileInfo::TYPE_FOLDER) { + return; + } + + match (true) { + $event instanceof NodeCreatedEvent => $this->handleNodeCreated(event: $event), + $event instanceof NodeDeletedEvent => $this->handleNodeDeleted(event: $event), + $event instanceof NodeTouchedEvent => $this->handleNodeTouched(event: $event), + $event instanceof NodeWrittenEvent => $this->handleNodeWritten(event: $event), + default => throw new InvalidArgumentException(message: 'Unsupported event type: ' . get_class($event)), + }; + } + + private function handleNodeCreated(NodeCreatedEvent $event): void { +// $this->objectService->nodeCreatedEventFunction(event: $event); + } + + private function handleNodeDeleted(NodeDeletedEvent $event): void { +// $this->objectService->nodeDeletedEventFunction(); + } + + private function handleNodeTouched(NodeTouchedEvent $event): void { +// $this->objectService->nodeTouchedEventFunction(); + } + + private function handleNodeWritten(NodeWrittenEvent $event): void { +// $this->objectService->nodeWrittenEventFunction(); + } +} diff --git a/lib/EventListener/AbstractNodesFolderEventListener.php b/lib/EventListener/AbstractNodesFolderEventListener.php new file mode 100644 index 0000000..8094c27 --- /dev/null +++ b/lib/EventListener/AbstractNodesFolderEventListener.php @@ -0,0 +1,51 @@ +getSource(); + if ($sourceNode->getType() === FileInfo::TYPE_FOLDER) { + return; + } + + match (true) { + $event instanceof NodeCopiedEvent => $this->handleNodeCopied(event: $event), + $event instanceof NodeRenamedEvent => $this->handleNodeRenamed(event: $event), + default => throw new InvalidArgumentException(message: 'Unsupported event type: ' . get_class($event)), + }; + } + + private function handleNodeCopied(NodeCopiedEvent $event): void { +// $this->objectService->nodeCopiedEventFunction(); + } + + private function handleNodeRenamed(NodeRenamedEvent $event): void { +// $this->objectService->nodeRenamedEventFunction(); + } +} diff --git a/lib/Migration/Version1Date20250115230511.php b/lib/Migration/Version1Date20250115230511.php new file mode 100644 index 0000000..8082a1c --- /dev/null +++ b/lib/Migration/Version1Date20250115230511.php @@ -0,0 +1,102 @@ +getTable('openregister_objects'); + + // Add locked column to store lock tokens as JSON array + if ($table->hasColumn('locked') === false) { + $table->addColumn('locked', Types::JSON, [ + 'notnull' => false, + 'default' => null, + ]); + } + + // Add owner column to store user ID of object owner + if ($table->hasColumn('owner') === false) { + $table->addColumn('owner', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + 'default' => '', + ]); + } + + // Add authorization column to store access permissions as JSON object + if ($table->hasColumn('authorization') === false) { + $table->addColumn('authorization', Types::TEXT, [ + 'notnull' => false, + 'default' => null, + ]); + } + + // Add folder column to store Nextcloud folder path + if ($table->hasColumn('folder') === false) { + $table->addColumn('folder', Types::STRING, [ + 'notnull' => false, + 'length' => 4000, + 'default' => '', + ]); + } + + // Update the openregister_registers table + $registersTable = $schema->getTable('openregister_registers'); + + // Add folder column to store Nextcloud folder path for registers + if ($registersTable->hasColumn('folder') === false) { + $registersTable->addColumn('folder', Types::STRING, [ + 'notnull' => false, + 'length' => 4000, + 'default' => '', + ]); + } + + return $schema; + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + } +} diff --git a/lib/Service/DownloadService.php b/lib/Service/DownloadService.php index 69559fe..c3711c2 100644 --- a/lib/Service/DownloadService.php +++ b/lib/Service/DownloadService.php @@ -34,7 +34,7 @@ public function __construct( * @return array The response data for the download request. * @throws Exception */ - public function download(string $objectType, string|int $id, string $accept) + public function download(string $objectType, string|int $id, string $accept): array { // Get the appropriate mapper for the object type $mapper = $this->getMapper($objectType); diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 2d9a832..edd20d3 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -5,15 +5,20 @@ use DateTime; use Exception; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; use OCP\AppFramework\Http\JSONResponse; use OCP\Files\File; use OCP\Files\GenericFileException; use OCP\Files\InvalidPathException; use OCP\Files\IRootFolder; +use OCP\Files\Node; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IConfig; +use OCP\IGroupManager; use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUserSession; @@ -31,6 +36,9 @@ */ class FileService { + const ROOT_FOLDER = 'Open Registers'; + const APP_GROUP = 'openregister'; + /** * Constructor for FileService * @@ -40,13 +48,92 @@ class FileService * @param IManager $shareManager The share manager interface */ public function __construct( - private readonly IUserSession $userSession, + private readonly IUserSession $userSession, private readonly LoggerInterface $logger, - private readonly IRootFolder $rootFolder, - private readonly IManager $shareManager, - private readonly IURLGenerator $urlGenerator, - private readonly IConfig $config, - ) {} + private readonly IRootFolder $rootFolder, + private readonly IManager $shareManager, + private readonly IURLGenerator $urlGenerator, + private readonly IConfig $config, + private readonly RegisterMapper $registerMapper, + private readonly SchemaMapper $schemaMapper, + private readonly IGroupManager $groupManager, + ) + { + } + + /** + * Creates a folder for a Register (used for storing files of Schemas/Objects). + * + * @param Register|int $register The Register to create the folder for. + * + * @return string The path to the folder. + * @throws Exception In case we can't create the folder because it is not permitted. + */ + public function createRegisterFolder(Register|int $register): string + { + if (is_int($register) === true) { + $register = $this->registerMapper->find($register); + } + + $registerFolderName = $this->getRegisterFolderName($register); + // @todo maybe we want to use ShareLink here for register->folder as well? + $register->setFolder($this::ROOT_FOLDER . "/$registerFolderName"); + + $folderPath = $this::ROOT_FOLDER . "/$registerFolderName"; + $this->createFolder(folderPath: $folderPath); + + return $folderPath; + } + + /** + * Get the name for the folder of a Register (used for storing files of Schemas/Objects). + * + * @param Register $register The Register to get the folder name for. + * + * @return string The name the folder for this Register should have. + */ + private function getRegisterFolderName(Register $register): string + { + $title = $register->getTitle(); + + if (str_ends_with(strtolower($title), 'register')) { + return $title; + } + + return "$title Register"; + } + + /** + * Creates a folder for a Schema (used for storing files of Objects). + * + * @param Register|int $register The Register to create the schema folder for. + * @param Schema|int $schema The Schema to create the folder for. + * + * @return string The path to the folder. + * @throws Exception In case we can't create the folder because it is not permitted. + */ + public function createSchemaFolder(Register|int $register, Schema|int $schema): string + { + if (is_int($register) === true) { + $register = $this->registerMapper->find($register); + } + + if (is_int($schema) === true) { + $schema = $this->schemaMapper->find($schema); + } + // @todo we could check here if Register contains/has Schema else throw Exception. + + $registerFolderName = $this->getRegisterFolderName($register); + // @todo maybe we want to use ShareLink here for register->folder as well? + $register->setFolder($this::ROOT_FOLDER . "/$registerFolderName"); + + $schemaFolderName = $this->getSchemaFolderName($schema); + + $folderPath = $this::ROOT_FOLDER . "/$registerFolderName/$schemaFolderName"; + $this->createFolder(folderPath: $folderPath); + + return $folderPath; + } /** * Get the name for the folder used for storing files of objects of a specific Schema. @@ -55,9 +142,137 @@ public function __construct( * * @return string The name the folder for this Schema should have. */ - public function getSchemaFolderName(Schema $schema): string + private function getSchemaFolderName(Schema $schema): string + { + return $schema->getTitle(); + } + + /** + * Creates a folder for an Object (used for storing files of this Object). + * + * @param ObjectEntity $objectEntity The Object to create the folder for. + * @param Register|int|null $register The Register to create the Object folder for. + * @param Schema|int|null $schema The Schema to create the Object folder for. + * + * @return Node|null The NextCloud Node object of the folder. Or null if something went wrong creating the folder. + * @throws Exception In case we can't create the folder because it is not permitted. + */ + public function createObjectFolder( + ObjectEntity $objectEntity, + Register|int|null $register = null, + Schema|int|null $schema = null, + string $folderPath = null + ): ?Node { - return "({$schema->getUuid()}) {$schema->getTitle()}"; + if ($folderPath === null) { + $folderPath = $this->getObjectFolderPath(objectEntity: $objectEntity, register: $register, schema: $schema); + } + $this->createFolder(folderPath: $folderPath); + + // @todo Do we want to use ShareLink here? + // @todo ^If so, we need to update these functions to be able to create shareLinks for folders as well (not only files) + $objectEntity->setFolder($folderPath); + +// // Create or find ShareLink +// $share = $this->fileService->findShare(path: $filePath); +// if ($share !== null) { +// $shareLink = $this->fileService->getShareLink($share); +// } else { +// $shareLink = $this->fileService->createShareLink(path: $filePath); +// } + + return $this->getNode($folderPath); + } + + /** + * Gets the NextCloud Node object for the folder of an Object. + * + * @param ObjectEntity $objectEntity The Object to get the folder for. + * @param Register|int|null $register The Register to get the Object folder for. + * @param Schema|int|null $schema The Schema to get the Object folder for. + * + * @return Node|null The NextCloud Node object of the folder. Or null if something went wrong getting / creating the folder. + * @throws Exception In case we can't create the folder because it is not permitted. + */ + public function getObjectFolder( + ObjectEntity $objectEntity, + Register|int|null $register = null, + Schema|int|null $schema = null + ): ?Node + { + if($objectEntity->getFolder() === null) { + $folderPath = $this->getObjectFolderPath( + objectEntity: $objectEntity, + register: $register, + schema: $schema + ); + } else { + $folderPath = $objectEntity->getFolder(); + } + + $node = $this->getNode($folderPath); + + if ($node === null) { + return $this->createObjectFolder( + objectEntity: $objectEntity, + register: $register, + schema: $schema, + folderPath: $folderPath + ); + } + return $node; + } + + /** + * Gets the path to the folder of an object. + * + * @param ObjectEntity $objectEntity The Object to get the folder path for. + * @param Register|int|null $register The Register to get the Object folder path for (must match Object->register). + * @param Schema|int|null $schema The Schema to get the Object folder path for (must match Object->schema). + * + * @return string The path to the folder. + * @throws Exception If something went wrong getting the path, a mismatch in object register/schema & function parameters register/schema for example. + */ + private function getObjectFolderPath( + ObjectEntity $objectEntity, + Register|int|null $register = null, + Schema|int|null $schema = null + ): string + { + $objectRegister = (int)$objectEntity->getRegister(); + if ($register === null) { + $register = $objectRegister; + } + if (is_int($register) === true) { + if ($register !== $objectRegister) { + $message = "Mismatch in Object->Register ($objectRegister) & Register given in function: getObjectFolderPath() ($register)"; + $this->logger->error(message: $message); + throw new Exception(message: $message); + } + $register = $this->registerMapper->find($register); + } + + $objectSchema = (int)$objectEntity->getSchema(); + if ($schema === null) { + $schema = $objectSchema; + } + if (is_int($schema) === true) { + if ($schema !== $objectSchema) { + $message = "Mismatch in Object->Schema ($objectSchema) & Schema given in function: getObjectFolderPath() ($schema)"; + $this->logger->error(message: $message); + throw new Exception(message: $message); + } + $schema = $this->schemaMapper->find($schema); + } + + $registerFolderName = $this->getRegisterFolderName($register); + // @todo maybe we want to use ShareLink here for register->folder as well? + $register->setFolder($this::ROOT_FOLDER . "/$registerFolderName"); + + $schemaFolderName = $this->getSchemaFolderName($schema); + $objectFolderName = $this->getObjectFolderName($objectEntity); + + return $this::ROOT_FOLDER . "/$registerFolderName/$schemaFolderName/$objectFolderName"; } /** @@ -67,12 +282,26 @@ public function getSchemaFolderName(Schema $schema): string * * @return string The name the folder for this object should have. */ - public function getObjectFolderName(ObjectEntity $objectEntity): string + private function getObjectFolderName(ObjectEntity $objectEntity): string { // @todo check if property Title or Name exists and use that as object title $objectTitle = 'object'; - return "({$objectEntity->getUuid()}) $objectTitle"; +// return "{$objectEntity->getUuid()} ($objectTitle)"; + return $objectEntity->getUuid(); + } + + /** + * Returns a link to the given folder path. + * + * @param string $folderPath The path to a folder in NextCloud. + * + * @return string The share link needed to get the file or folder for the given IShare object. + */ + private function getFolderLink(string $folderPath): string + { + $folderPath = str_replace('%2F', '/', urlencode($folderPath)); + return $this->getCurrentDomain() . "/index.php/apps/files/files?dir=$folderPath"; } /** @@ -104,6 +333,42 @@ private function getCurrentDomain(): string return $baseUrl; } + /** + * Gets a NextCloud Node object for the given file or folder path. + * + * @param string $path A path to a file or folder in NextCloud. + * + * @return Node|null The Node object found for a file or folder. Or null if not found. + * @throws NotPermittedException When not allowed to get the user folder. + */ + private function getNode(string $path): ?Node + { + // Get the current user. + $currentUser = $this->userSession->getUser(); + $userFolder = $this->rootFolder->getUserFolder(userId: $currentUser ? $currentUser->getUID() : 'Guest'); + + try { + return $node = $userFolder->get(path: $path); + } catch (NotFoundException $e) { + $this->logger->error(message: $e->getMessage()); + return null; + } + } + + /** + * @param Node $file + * @param int $shareType + * @return IShare[] + */ + public function findShares(Node $file, int $shareType = 3): array + { + // Get the current user. + $currentUser = $this->userSession->getUser(); + $userId = $currentUser ? $currentUser->getUID() : 'Guest'; + + return $this->shareManager->getSharesBy(userId: $userId, shareType: $shareType, path: $file); + } + /** * Try to find a IShare object with given $path & $shareType. * @@ -146,6 +411,36 @@ public function findShare(string $path, ?int $shareType = 3): ?IShare return null; } + /** + * Share a file or folder with a user group in Nextcloud. + * + * @param int $nodeId The file or folder to share. + * @param string $nodeType 'file' or 'folder', the type of node. + * @param string $target The target folder to share the node in. + * @param int $permissions Permissions the group members will have in the folder. + * @param string $groupId The id of the group to share the folder with. + * + * @return IShare The resulting share + * @throws Exception + */ + private function shareWithGroup(int $nodeId, string $nodeType, string $target, int $permissions, string $groupId): IShare + { + $share = $this->shareManager->newShare(); + $share->setTarget(target: '/'. $target); + $share->setNodeId(fileId:$nodeId); + $share->setNodeType(type:$nodeType); + + $share->setShareType(shareType: 1); + $share->setPermissions(permissions: $permissions); + $share->setSharedBy(sharedBy:$this->userSession->getUser()->getUID()); + $share->setShareOwner(shareOwner:$this->userSession->getUser()->getUID()); + $share->setShareTime(shareTime: new DateTime()); + $share->setSharedWith(sharedWith: $groupId); + $share->setStatus(status: $share::STATUS_ACCEPTED); + + return $this->shareManager->createShare($share); + } + /** * Creates a IShare object using the $shareData array data. * @@ -241,6 +536,7 @@ public function createShareLink(string $path, ?int $shareType = 3, ?int $permiss */ public function createFolder(string $folderPath): bool { + $folderPath = trim(string: $folderPath, characters: '/'); // Get the current user. @@ -249,6 +545,25 @@ public function createFolder(string $folderPath): bool // Check if folder exists and if not create it. try { + // First, check if the root folder exists, and if not, create it and share it with the openregister group. + try { + $userFolder->get(self::ROOT_FOLDER); + } catch(NotFoundException $exception) { + $rootFolder = $userFolder->newFolder(self::ROOT_FOLDER); + + if($this->groupManager->groupExists(self::APP_GROUP) === false) { + $this->groupManager->createGroup(self::APP_GROUP); + } + + $this->shareWithGroup( + nodeId: $rootFolder->getId(), + nodeType: $rootFolder->getType() === 'file' ? $rootFolder->getType() : 'folder', + target: self::ROOT_FOLDER, + permissions: 31, + groupId: self::APP_GROUP + ); + } + try { $userFolder->get(path: $folderPath); } catch (NotFoundException $e) { diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php old mode 100755 new mode 100644 index 986a46c..5affa5c --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -8,12 +8,9 @@ use GuzzleHttp\Exception\GuzzleException; use InvalidArgumentException; use JsonSerializable; -use OCA\OpenConnector\Twig\MappingExtension; -use OCA\OpenConnector\Twig\MappingRuntimeLoader; +use OC\Files\Node\Node; use OCA\OpenRegister\Db\File; use OCA\OpenRegister\Db\FileMapper; -use OCA\OpenRegister\Db\Source; -use OCA\OpenRegister\Db\SourceMapper; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Db\Register; @@ -25,6 +22,8 @@ use OCA\OpenRegister\Exception\ValidationException; use OCA\OpenRegister\Formats\BsnFormat; use OCP\App\IAppManager; +use OCP\Files\Events\Node\NodeCreatedEvent; +use OCP\Files\Folder; use OCP\IAppConfig; use OCP\IURLGenerator; use Opis\JsonSchema\ValidationResult; @@ -89,7 +88,6 @@ public function __construct( ) { $this->twig = new Environment($loader); - $this->twig->addExtension(new MappingExtension()); } /** @@ -190,13 +188,14 @@ public function validateObject(array $object, ?int $schemaId = null, object $sch * @return ObjectEntity|null The found object or null if not found * @throws Exception If the object is not found. */ - public function find(int|string $id, ?array $extend = []): ?ObjectEntity + public function find(int|string $id, ?array $extend = [], bool $files = false): ?ObjectEntity { return $this->getObject( $this->registerMapper->find($this->getRegister()), $this->schemaMapper->find($this->getSchema()), $id, - $extend + $extend, + files: $files ); } @@ -311,7 +310,8 @@ public function findAll( array $filters = [], array $sort = [], ?string $search = null, - ?array $extend = [] + ?array $extend = [], + bool $files = false ): array { $objects = $this->getObjects( @@ -321,7 +321,8 @@ public function findAll( offset: $offset, filters: $filters, sort: $sort, - search: $search + search: $search, + files: $files ); // If extend is provided, extend each object @@ -515,7 +516,8 @@ public function getObjects( ?int $offset = null, array $filters = [], array $sort = [], - ?string $search = null + ?string $search = null, + bool $files = true, ) { // Set object type and filters if register and schema are provided @@ -528,13 +530,25 @@ public function getObjects( $mapper = $this->getMapper($objectType); // Use the mapper to find and return all objects of the specified type - return $mapper->findAll( + $objects = $mapper->findAll( limit: $limit, offset: $offset, filters: $filters, sort: $sort, search: $search ); + + if($files === false) { + return $objects; + } + + $objects = array_map(function($object) { + $files = $this->getFiles($object); + return $this->hydrateFiles($object, $files); + }, $objects); + + return $objects; + } /** @@ -550,8 +564,7 @@ public function getObjects( */ public function saveObject(int $register, int $schema, array $object, ?int $depth = null): ObjectEntity { - - // Remove system properties (starting with _) + // Remove system properties (starting with _) $object = array_filter($object, function($key) { return !str_starts_with($key, '_'); }, ARRAY_FILTER_USE_KEY); @@ -598,6 +611,9 @@ public function saveObject(int $register, int $schema, array $object, ?int $dept $object['id'] = $objectEntity->getUuid(); } + // Make sure we create a folder in NC for this object + $this->fileService->createObjectFolder($objectEntity); + // Store old version for audit trail $oldObject = clone $objectEntity; $objectEntity->setObject($object); @@ -623,9 +639,11 @@ public function saveObject(int $register, int $schema, array $object, ?int $dept if ($objectEntity->getId() && ($schemaObject->getHardValidation() === false || $validationResult->isValid() === true)) { $objectEntity = $this->objectEntityMapper->update($objectEntity); + // Create audit trail for update $this->auditTrailMapper->createAuditTrail(new: $objectEntity, old: $oldObject); } else if ($schemaObject->getHardValidation() === false || $validationResult->isValid() === true) { $objectEntity = $this->objectEntityMapper->insert($objectEntity); + // Create audit trail for creation $this->auditTrailMapper->createAuditTrail(new: $objectEntity); } @@ -1088,18 +1106,13 @@ private function writeFile(string $fileContent, string $propertyName, ObjectEnti $fileName = $file->getFilename(); try { - $schema = $this->schemaMapper->find($objectEntity->getSchema()); - $schemaFolder = $this->fileService->getSchemaFolderName($schema); - $objectFolder = $this->fileService->getObjectFolderName($objectEntity); - - $this->fileService->createFolder(folderPath: 'Objects'); - $this->fileService->createFolder(folderPath: "Objects/$schemaFolder"); - $this->fileService->createFolder(folderPath: "Objects/$schemaFolder/$objectFolder"); + $folderNode = $this->fileService->createObjectFolder($objectEntity); + $folderPath = $folderNode->getPath(); $filePath = $file->getFilePath(); if ($filePath === null) { - $filePath = "Objects/$schemaFolder/$objectFolder/$fileName"; + $filePath = "$folderPath/$fileName"; } $succes = $this->fileService->updateFile( @@ -1289,6 +1302,91 @@ private function handleFileProperty(ObjectEntity $objectEntity, array $object, s } } + /** + * Get files for object + * + * See https://nextcloud-server.netlify.app/classes/ocp-files-file for the Nextcloud documentation on the File class + * See https://nextcloud-server.netlify.app/classes/ocp-files-node for the Nextcloud documentation on the Node superclass + * + * @param ObjectEntity|string $object The object or object ID to fetch files for + * @return Node[] The files found + * @throws \OCP\Files\NotFoundException If the folder is not found + * @throws DoesNotExistException If the object ID is not found + */ + public function getFiles(ObjectEntity|string $object): array + { + // If string ID provided, try to find the object entity + if (is_string($object)) { + $object = $this->objectEntityMapper->find($object); + } + + $folder = $this->fileService->getObjectFolder( + objectEntity: $object, + register: $object->getRegister(), + schema: $object->getSchema() + ); + + if ($folder instanceof Folder === true) { + $files = $folder->getDirectoryListing(); + } + + return $files; + } + + /** + * Formats an array of Node files into an array of metadata arrays. + * + * See https://nextcloud-server.netlify.app/classes/ocp-files-file for the Nextcloud documentation on the File class + * See https://nextcloud-server.netlify.app/classes/ocp-files-node for the Nextcloud documentation on the Node superclass + * + * @param Node[] $files Array of Node files to format + * @return array Array of formatted file metadata arrays + */ + public function formatFiles(array $files): array + { + $formattedFiles = []; + + foreach($files as $file) { + // IShare documentation see https://nextcloud-server.netlify.app/classes/ocp-share-ishare + $shares = $this->fileService->findShares($file); + + $formattedFile = [ + 'id' => $file->getId(), + 'path' => $file->getPath(), + 'title' => $file->getName(), + 'accessUrl' => count($shares) > 0 ? $this->fileService->getShareLink($shares[0]) : null, + 'downloadUrl' => count($shares) > 0 ? $this->fileService->getShareLink($shares[0]).'/download' : null, + 'type' => $file->getMimetype(), + 'extension' => $file->getExtension(), + 'size' => $file->getSize(), + 'hash' => $file->getEtag(), + 'published' => (new DateTime())->setTimestamp($file->getCreationTime())->format('c'), + 'modified' => (new DateTime())->setTimestamp($file->getUploadTime())->format('c'), + ]; + + $formattedFiles[] = $formattedFile; + } + + return $formattedFiles; + } + + /** + * Hydrate files array with metadata. + * + * See https://nextcloud-server.netlify.app/classes/ocp-files-file for the Nextcloud documentation on the File class + * See https://nextcloud-server.netlify.app/classes/ocp-files-node for the Nextcloud documentation on the Node superclass + * + * @param ObjectEntity $object The object to hydrate the files array of. + * @param Node[] $files The files to hydrate the files array with. + * @return ObjectEntity The object with hydrated files array. + */ + public function hydrateFiles(ObjectEntity $object, array $files): ObjectEntity + { + $formattedFiles = $this->formatFiles($files); + $object->setFiles($formattedFiles); + return $object; + } + /** * Retrieves an object from a specified register and schema using its UUID. * @@ -1302,12 +1400,19 @@ private function handleFileProperty(ObjectEntity $objectEntity, array $object, s * @return ObjectEntity The retrieved object as an entity. * @throws Exception If the source type is unsupported. */ - public function getObject(Register $register, Schema $schema, string $uuid, ?array $extend = []): ObjectEntity + public function getObject(Register $register, Schema $schema, string $uuid, ?array $extend = [], bool $files = false): ObjectEntity { // Handle internal source if ($register->getSource() === 'internal' || $register->getSource() === '') { - return $this->objectEntityMapper->findByUuid($register, $schema, $uuid); + $object = $this->objectEntityMapper->findByUuid($register, $schema, $uuid); + + if($files === false) { + return $object; + } + + $files = $this->getFiles($object); + return $this->hydrateFiles($object, $files); } //@todo mongodb support @@ -1817,4 +1922,114 @@ public function setDefaults(ObjectEntity $objectEntity): ObjectEntity return $objectEntity; } + + /** + * Lock an object + * + * @param string|int $identifier Object ID, UUID, or URI + * @param string|null $process Optional process identifier + * @param int|null $duration Lock duration in seconds (default: 1 hour) + * @return ObjectEntity The locked object + * @throws NotFoundException If object not found + * @throws NotAuthorizedException If user not authorized + * @throws LockedException If object already locked by another user + */ + public function lockObject($identifier, ?string $process = null, ?int $duration = 3600): ObjectEntity + { + try { + return $this->objectEntityMapper->lockObject( + $identifier, + $process, + $duration + ); + } catch (DoesNotExistException $e) { + throw new NotFoundException('Object not found'); + } catch (\Exception $e) { + if (str_contains($e->getMessage(), 'Must be logged in')) { + throw new NotAuthorizedException($e->getMessage()); + } + throw new LockedException($e->getMessage()); + } + } + + /** + * Unlock an object + * + * @param string|int $identifier Object ID, UUID, or URI + * @return ObjectEntity The unlocked object + * @throws NotFoundException If object not found + * @throws NotAuthorizedException If user not authorized + * @throws LockedException If object locked by another user + */ + public function unlockObject($identifier): ObjectEntity + { + try { + return $this->objectEntityMapper->unlockObject($identifier); + } catch (DoesNotExistException $e) { + throw new NotFoundException('Object not found'); + } catch (\Exception $e) { + if (str_contains($e->getMessage(), 'Must be logged in')) { + throw new NotAuthorizedException($e->getMessage()); + } + throw new LockedException($e->getMessage()); + } + } + + /** + * Check if an object is locked + * + * @param string|int $identifier Object ID, UUID, or URI + * @return bool True if object is locked, false otherwise + * @throws NotFoundException If object not found + */ + public function isLocked($identifier): bool + { + try { + return $this->objectEntityMapper->isObjectLocked($identifier); + } catch (DoesNotExistException $e) { + throw new NotFoundException('Object not found'); + } + } + + /** + * Revert an object to a previous state + * + * @param string|int $identifier Object ID, UUID, or URI + * @param DateTime|string|null $until DateTime or AuditTrail ID to revert to + * @param bool $overwriteVersion Whether to overwrite the version or increment it + * @return ObjectEntity The reverted object + * @throws NotFoundException If object not found + * @throws NotAuthorizedException If user not authorized + * @throws \Exception If revert fails + */ + public function revertObject($identifier, $until = null, bool $overwriteVersion = false): ObjectEntity + { + try { + // Get the reverted object (unsaved) + $revertedObject = $this->auditTrailMapper->revertObject( + $identifier, + $until, + $overwriteVersion + ); + + // Save the reverted object + $revertedObject = $this->objectEntityMapper->update($revertedObject); + + // Dispatch revert event + $this->eventDispatcher->dispatch( + ObjectRevertedEvent::class, + new ObjectRevertedEvent($revertedObject, $until) + ); + + return $revertedObject; + + } catch (DoesNotExistException $e) { + throw new NotFoundException('Object not found'); + } catch (\Exception $e) { + if (str_contains($e->getMessage(), 'Must be logged in')) { + throw new NotAuthorizedException($e->getMessage()); + } + throw $e; + } + } } diff --git a/lib/Service/UploadService.php b/lib/Service/UploadService.php index 7699788..a9b3b35 100644 --- a/lib/Service/UploadService.php +++ b/lib/Service/UploadService.php @@ -81,6 +81,11 @@ public function getUploadedJson(array $data): array|JSONResponse return $phpArray; } + /** + * Gets uploaded file form request and returns it as PHP array to use for creating/updating an object. + * + * @return array|JSONResponse + */ private function getJSONfromFile(): array|JSONResponse { // @todo diff --git a/src/entities/object/object.mock.ts b/src/entities/object/object.mock.ts index 0eaab47..a1093e8 100644 --- a/src/entities/object/object.mock.ts +++ b/src/entities/object/object.mock.ts @@ -11,8 +11,11 @@ export const mockObjectData = (): TObject[] => [ object: JSON.stringify({ key: 'value' }), relations: JSON.stringify({ key: 'value' }), files: JSON.stringify({ key: 'value' }), + folder: 'https://example.com/character/1234a1e5-b54d-43ad-abd1-4b5bff5fcd3f', created: new Date().toISOString(), updated: new Date().toISOString(), + locked: ['token1', 'token2'], // Array of lock tokens + owner: 'user1', // Owner of the object }, { id: '5678a1e5-b54d-43ad-abd1-4b5bff5fcd3f', @@ -23,8 +26,11 @@ export const mockObjectData = (): TObject[] => [ object: JSON.stringify({ key: 'value' }), relations: JSON.stringify({ key: 'value' }), files: JSON.stringify({ key: 'value' }), + folder: 'https://example.com/item/5678a1e5-b54d-43ad-abd1-4b5bff5fcd3f', created: new Date().toISOString(), updated: new Date().toISOString(), + locked: null, // Not locked + owner: 'user2', // Owner of the object }, ] diff --git a/src/entities/object/object.spec.ts b/src/entities/object/object.spec.ts index f66583e..9b90242 100644 --- a/src/entities/object/object.spec.ts +++ b/src/entities/object/object.spec.ts @@ -25,6 +25,19 @@ describe('Object Entity', () => { expect(object.files).toBe(mockObjectData()[0].files) expect(object.updated).toBe(mockObjectData()[0].updated) expect(object.created).toBe(mockObjectData()[0].created) + expect(object.locked).toBe(null) + expect(object.owner).toBe('') + expect(object.validate().success).toBe(true) + }) + + it('should handle locked array and owner string', () => { + const mockData = mockObjectData()[0] + mockData.locked = ['token1', 'token2'] + mockData.owner = 'user1' + const object = new ObjectEntity(mockData) + + expect(object.locked).toEqual(['token1', 'token2']) + expect(object.owner).toBe('user1') expect(object.validate().success).toBe(true) }) diff --git a/src/entities/object/object.ts b/src/entities/object/object.ts index cec2b84..4c9df86 100644 --- a/src/entities/object/object.ts +++ b/src/entities/object/object.ts @@ -1,6 +1,9 @@ import { SafeParseReturnType, z } from 'zod' import { TObject } from './object.types' +/** + * Entity class representing an Object with validation + */ export class ObjectEntity implements TObject { public id: string @@ -11,8 +14,11 @@ export class ObjectEntity implements TObject { public object: string public relations: string public files: string + public folder: string public updated: string public created: string + public locked: string[] | null // Array of lock tokens or null if not locked + public owner: string // Owner of the object constructor(object: TObject) { this.id = object.id || '' @@ -23,10 +29,17 @@ export class ObjectEntity implements TObject { this.object = object.object this.relations = object.relations this.files = object.files + this.folder = object.folder || '' this.updated = object.updated || '' this.created = object.created || '' + this.locked = object.locked || null + this.owner = object.owner || '' } + /** + * Validates the object against a schema + * @return {SafeParseReturnType} Object containing validation result with success/error status + */ public validate(): SafeParseReturnType { const schema = z.object({ id: z.string().min(1), @@ -36,8 +49,11 @@ export class ObjectEntity implements TObject { object: z.string(), relations: z.string(), files: z.string(), + folder: z.string(), updated: z.string(), created: z.string(), + locked: z.array(z.string()).nullable(), + owner: z.string(), }) return schema.safeParse(this) diff --git a/src/entities/object/object.types.ts b/src/entities/object/object.types.ts index b37dc3c..2b55d1a 100644 --- a/src/entities/object/object.types.ts +++ b/src/entities/object/object.types.ts @@ -7,6 +7,9 @@ export type TObject = { object: string // JSON object relations: string files: string + folder: string updated: string created: string + locked: string[] | null // Array of lock tokens or null if not locked + owner: string // Owner of the object } diff --git a/src/modals/Modals.vue b/src/modals/Modals.vue index fc22305..1c9825e 100644 --- a/src/modals/Modals.vue +++ b/src/modals/Modals.vue @@ -17,6 +17,7 @@ import { navigationStore } from '../store/store.js' + @@ -37,6 +38,7 @@ import EditObject from './object/EditObject.vue' import DeleteObject from './object/DeleteObject.vue' import UploadObject from './object/UploadObject.vue' import ViewObjectAuditTrail from './objectAuditTrail/ViewObjectAuditTrail.vue' +import LockObject from './object/LockObject.vue' export default { name: 'Modals', @@ -55,6 +57,7 @@ export default { DeleteObject, UploadObject, ViewObjectAuditTrail, + LockObject, }, } diff --git a/src/modals/object/LockObject.vue b/src/modals/object/LockObject.vue new file mode 100644 index 0000000..9ad6550 --- /dev/null +++ b/src/modals/object/LockObject.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/src/store/modules/object.js b/src/store/modules/object.js index b8befb9..b59fc87 100644 --- a/src/store/modules/object.js +++ b/src/store/modules/object.js @@ -10,11 +10,18 @@ export const useObjectStore = defineStore('object', { auditTrails: [], relationItem: false, relations: [], + fileItem: false, // Single file item + files: [], // List of files }), actions: { - setObjectItem(objectItem) { + async setObjectItem(objectItem) { this.objectItem = objectItem && new ObjectEntity(objectItem) console.log('Active object item set to ' + objectItem) + + // Get files when object is set + if (objectItem && objectItem.id) { + await this.getFiles(objectItem.id) + } }, setObjectList(objectList) { this.objectList = objectList.map( @@ -38,6 +45,12 @@ export const useObjectStore = defineStore('object', { (relation) => new ObjectEntity(relation), ) }, + setFileItem(fileItem) { + this.fileItem = fileItem + }, + setFiles(files) { + this.files = files + }, /* istanbul ignore next */ // ignore this for Jest until moved into a service async refreshObjectList(search = null) { // @todo this might belong in a service? @@ -194,6 +207,38 @@ export const useObjectStore = defineStore('object', { return { response, data } }, + // FILES + /** + * Get files for an object + * + * @param {number} id Object ID + * @return {Promise} Promise that resolves with the object's files + */ + async getFiles(id) { + if (!id) { + throw new Error('No object id to get files for') + } + + const endpoint = `/index.php/apps/openregister/api/objects/files/${id}` + + try { + const response = await fetch(endpoint, { + method: 'GET', + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const data = await response.json() + this.setFiles(data || []) + + return { response, data } + } catch (error) { + console.error('Error getting files:', error) + throw new Error(`Failed to get files: ${error.message}`) + } + }, // mappings async getMappings() { const endpoint = '/index.php/apps/openregister/api/objects/mappings' @@ -206,5 +251,107 @@ export const useObjectStore = defineStore('object', { return { response, data } }, + /** + * Lock an object + * + * @param {number} id Object ID + * @param {string|null} process Optional process identifier + * @param {number|null} duration Lock duration in seconds + * @return {Promise} Promise that resolves when the object is locked + */ + async lockObject(id, process = null, duration = null) { + const endpoint = `/index.php/apps/openregister/api/objects/${id}/lock` + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + process, + duration, + }), + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const data = await response.json() + this.setObjectItem(data) + this.refreshObjectList() + + return { response, data } + } catch (error) { + console.error('Error locking object:', error) + throw new Error(`Failed to lock object: ${error.message}`) + } + }, + /** + * Unlock an object + * + * @param {number} id Object ID + * @return {Promise} Promise that resolves when the object is unlocked + */ + async unlockObject(id) { + const endpoint = `/index.php/apps/openregister/api/objects/${id}/unlock` + + try { + const response = await fetch(endpoint, { + method: 'POST', + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const data = await response.json() + this.setObjectItem(data) + this.refreshObjectList() + + return { response, data } + } catch (error) { + console.error('Error unlocking object:', error) + throw new Error(`Failed to unlock object: ${error.message}`) + } + }, + /** + * Revert an object to a previous state + * + * @param {number} id Object ID + * @param {object} options Revert options + * @param {string} [options.datetime] ISO datetime string + * @param {string} [options.auditTrailId] Audit trail ID + * @param {string} [options.version] Semantic version + * @param {boolean} [options.overwriteVersion] Whether to overwrite version + * @return {Promise} Promise that resolves when the object is reverted + */ + async revertObject(id, options) { + const endpoint = `/index.php/apps/openregister/api/objects/${id}/revert` + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(options), + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const data = await response.json() + this.setObjectItem(data) + this.refreshObjectList() + + return { response, data } + } catch (error) { + console.error('Error reverting object:', error) + throw new Error(`Failed to revert object: ${error.message}`) + } + }, }, }) diff --git a/src/views/object/ObjectDetails.vue b/src/views/object/ObjectDetails.vue index 4160618..ae466a1 100644 --- a/src/views/object/ObjectDetails.vue +++ b/src/views/object/ObjectDetails.vue @@ -13,7 +13,8 @@ import { objectStore, navigationStore } from '../../store/store.js' Edit + + + Lock + + + + Unlock + Delete + + + Open Folder + + + + + This object is locked by {{ objectStore.objectItem.locked.user }} + {{ objectStore.objectItem.locked.process ? `for process "${objectStore.objectItem.locked.process}"` : '' }} + until {{ new Date(objectStore.objectItem.locked.expiration).toLocaleString() }} + + Uri: {{ objectStore.objectItem.uri }}
@@ -39,6 +73,10 @@ import { objectStore, navigationStore } from '../../store/store.js' Schema:

{{ objectStore.objectItem.schema }}

+
+ Folder: +

{{ objectStore.objectItem.folder || '-' }}

+
Updated:

{{ objectStore.objectItem.updated }}

@@ -97,8 +135,37 @@ import { objectStore, navigationStore } from '../../store/store.js'
-
- {{ JSON.stringify(objectStore.objectItem.files, null, 2) }} + + + Open folder + +
+ + + + + +
+
+ No files found
@@ -148,6 +215,8 @@ import { NcActions, NcActionButton, NcListItem, + NcNoteCard, + NcButton, } from '@nextcloud/vue' import { BTabs, BTab } from 'bootstrap-vue' @@ -157,6 +226,10 @@ import TrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue' import TimelineQuestionOutline from 'vue-material-design-icons/TimelineQuestionOutline.vue' import Eye from 'vue-material-design-icons/Eye.vue' import CubeOutline from 'vue-material-design-icons/CubeOutline.vue' +import LockOutline from 'vue-material-design-icons/LockOutline.vue' +import LockOpenOutline from 'vue-material-design-icons/LockOpenOutline.vue' +import FolderOutline from 'vue-material-design-icons/FolderOutline.vue' +import FileOutline from 'vue-material-design-icons/FileOutline.vue' export default { name: 'ObjectDetails', @@ -164,6 +237,8 @@ export default { NcActions, NcActionButton, NcListItem, + NcNoteCard, + NcButton, BTabs, BTab, DotsHorizontal, @@ -172,6 +247,10 @@ export default { TimelineQuestionOutline, CubeOutline, Eye, + LockOutline, + LockOpenOutline, + FolderOutline, + FileOutline, }, data() { return { @@ -215,6 +294,41 @@ export default { this.relationsLoading = false }) }, + /** + * Opens the folder URL in a new tab after parsing the encoded URL and converting to Nextcloud format + * @param {string} url - The encoded folder URL to open (e.g. "Open Registers\/Publicatie Register\/Publicatie\/123") + */ + openFolder(url) { + // Parse the encoded URL by replacing escaped characters + const decodedUrl = url.replace(/\\\//g, '/') + + // Ensure URL starts with forward slash + const normalizedUrl = decodedUrl.startsWith('/') ? decodedUrl : '/' + decodedUrl + + // Construct the proper Nextcloud Files app URL with the normalized path + // Use window.location.origin to get the current domain instead of hardcoding + const nextcloudUrl = `${window.location.origin}/index.php/apps/files/files?dir=${encodeURIComponent(normalizedUrl)}` + + // Open URL in new tab + window.open(nextcloudUrl, '_blank') + }, + /** + * Opens a file in the Nextcloud Files app + * @param {object} file - The file object containing id, path, and other metadata + */ + openFile(file) { + // Extract the directory path without the filename + const dirPath = file.path.substring(0, file.path.lastIndexOf('/')) + + // Remove the '/admin/files/' prefix if it exists + const cleanPath = dirPath.replace(/^\/admin\/files\//, '/') + + // Construct the proper Nextcloud Files app URL with file ID and openfile parameter + const filesAppUrl = `/index.php/apps/files/files/${file.id}?dir=${encodeURIComponent(cleanPath)}&openfile=true` + + // Open URL in new tab + window.open(filesAppUrl, '_blank') + }, }, } diff --git a/src/views/object/ObjectsList.vue b/src/views/object/ObjectsList.vue index 184b869..932a8fe 100644 --- a/src/views/object/ObjectsList.vue +++ b/src/views/object/ObjectsList.vue @@ -58,6 +58,20 @@ import { objectStore, navigationStore, searchStore } from '../../store/store.js' Edit + + + Lock + + + + Unlock +