diff --git a/lib/Db/ObjectEntity.php b/lib/Db/ObjectEntity.php index 68c9245..dc73772 100644 --- a/lib/Db/ObjectEntity.php +++ b/lib/Db/ObjectEntity.php @@ -9,20 +9,26 @@ class ObjectEntity extends Entity implements JsonSerializable { protected ?string $uuid = null; + protected ?string $uri = null; protected ?string $version = null; protected ?string $register = null; protected ?string $schema = null; protected ?array $object = []; + 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 ?DateTime $updated = null; protected ?DateTime $created = null; public function __construct() { - $this->addType(fieldName:'uuid', type: 'string'); + $this->addType(fieldName:'uuid', type: 'string'); + $this->addType(fieldName:'uri', type: 'string'); $this->addType(fieldName:'version', type: 'string'); $this->addType(fieldName:'register', type: 'string'); $this->addType(fieldName:'schema', type: 'string'); $this->addType(fieldName:'object', type: 'json'); + $this->addType(fieldName:'files', type: 'json'); + $this->addType(fieldName:'relations', type: 'json'); $this->addType(fieldName:'textRepresentation', type: 'text'); $this->addType(fieldName:'updated', type: 'datetime'); $this->addType(fieldName:'created', type: 'datetime'); @@ -72,10 +78,13 @@ public function getObjectArray(): array return [ 'id' => $this->id, 'uuid' => $this->uuid, + 'uri' => $this->uri, 'version' => $this->version, 'register' => $this->register, 'schema' => $this->schema, 'object' => $this->object, + 'files' => $this->files, + 'relations' => $this->relations, 'textRepresentation' => $this->textRepresentation, 'updated' => isset($this->updated) ? $this->updated->format('c') : null, 'created' => isset($this->created) ? $this->created->format('c') : null diff --git a/lib/Migration/Version1Date20241128221000.php b/lib/Migration/Version1Date20241128221000.php new file mode 100644 index 0000000..6543146 --- /dev/null +++ b/lib/Migration/Version1Date20241128221000.php @@ -0,0 +1,78 @@ +getTable('openregister_objects'); + if ($table->hasColumn('uri') === false) { + $table->addColumn( + name: 'uri', + typeName: Types::STRING, + options: [ + 'notnull' => true, + 'length' => 255 + ] + )->setDefault(''); + } + if ($table->hasColumn('files') === false) { + $table->addColumn( + name: 'files', + typeName: Types::JSON, + options: ['notnull' => false] + )->setDefault('{}'); + } + if ($table->hasColumn('relations') === false) { + $table->addColumn( + name: 'relations', + typeName: Types::JSON, + options: ['notnull' => false] + )->setDefault('{}');; + } + + 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/FileService.php b/lib/Service/FileService.php new file mode 100644 index 0000000..ea608fc --- /dev/null +++ b/lib/Service/FileService.php @@ -0,0 +1,390 @@ +getUuid()}) {$schema->getTitle()}"; + } + + /** + * Get the name for the folder used for storing files of the given object. + * + * @param ObjectEntity $objectEntity The Object to get the folder name for. + * + * @return string The name the folder for this object should have. + */ + public 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"; + } + + /** + * Returns a share link for the given IShare object. + * + * @param IShare $share An IShare object we are getting the share link for. + * + * @return string The share link needed to get the file or folder for the given IShare object. + */ + public function getShareLink(IShare $share): string + { + return $this->getCurrentDomain() . '/index.php/s/' . $share->getToken(); + } + + /** + * Gets and returns the current host / domain with correct protocol. + * + * @return string The current http/https domain url. + */ + private function getCurrentDomain(): string + { + // Check if the request is over HTTPS + $isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; + $protocol = $isHttps ? 'https://' : 'http://'; + + // Get the host (domain) + $host = $_SERVER['HTTP_HOST']; + + // Construct the full URL + return $protocol . $host; + } + + /** + * Try to find a IShare object with given $path & $shareType. + * + * @param string $path The path to a file we are trying to find a IShare object for. + * @param int|null $shareType The shareType of the share we are trying to find. + * + * @return IShare|null An IShare object or null. + */ + public function findShare(string $path, ?int $shareType = 3): ?IShare + { + $path = trim(string: $path, characters: '/'); + + // Get the current user. + $currentUser = $this->userSession->getUser(); + $userId = $currentUser ? $currentUser->getUID() : 'Guest'; + try { + $userFolder = $this->rootFolder->getUserFolder(userId: $userId); + } catch (NotPermittedException) { + $this->logger->error("Can't find share for $path because user (folder) for user $userId couldn't be found"); + + return null; + } + + try { + // Note: if we ever want to find shares for folders instead of files, this should work for folders as well? + $file = $userFolder->get(path: $path); + } catch (NotFoundException $e) { + $this->logger->error("Can't find share for $path because file doesn't exist"); + + return null; + } + + if ($file instanceof File) { + $shares = $this->shareManager->getSharesBy(userId: $userId, shareType: $shareType, path: $file); + if (count($shares) > 0) { + return $shares[0]; + } + } + + return null; + } + + /** + * Creates a IShare object using the $shareData array data. + * + * @param array $shareData The data to create a IShare with, should contain 'path', 'file', 'shareType', 'permissions' and 'userid'. + * + * @return IShare The Created IShare object. + * @throws Exception + */ + private function createShare(array $shareData) :IShare + { + // Create a new share + $share = $this->shareManager->newShare(); + $share->setTarget(target: '/'.$shareData['path']); + $share->setNodeId(fileId: $shareData['file']->getId()); + $share->setNodeType(type: 'file'); + $share->setShareType(shareType: $shareData['shareType']); + if ($shareData['permissions'] !== null) { + $share->setPermissions(permissions: $shareData['permissions']); + } + $share->setSharedBy(sharedBy: $shareData['userId']); + $share->setShareOwner(shareOwner: $shareData['userId']); + $share->setShareTime(shareTime: new DateTime()); + $share->setStatus(status: $share::STATUS_ACCEPTED); + + return $this->shareManager->createShare(share: $share); + } + + /** + * Creates and returns a share link for a file (or folder). + * (https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-share-api.html#create-a-new-share) + * + * @param string $path Path (from root) to the file/folder which should be shared. + * @param int|null $shareType 0 = user; 1 = group; 3 = public link; 4 = email; 6 = federated cloud share; 7 = circle; 10 = Talk conversation + * @param int|null $permissions 1 = read; 2 = update; 4 = create; 8 = delete; 16 = share; 31 = all (default: 31, for public shares: 1) + * + * @return string The share link. + * @throws Exception In case creating the share(link) fails. + */ + public function createShareLink(string $path, ?int $shareType = 3, ?int $permissions = null): string + { + $path = trim(string: $path, characters: '/'); + if ($permissions === null) { + $permissions = 31; + if ($shareType === 3) { + $permissions = 1; + } + } + + // Get the current user. + $currentUser = $this->userSession->getUser(); + $userId = $currentUser ? $currentUser->getUID() : 'Guest'; + try { + $userFolder = $this->rootFolder->getUserFolder(userId: $userId); + } catch (NotPermittedException) { + $this->logger->error("Can't create share link for $path because user (folder) for user $userId couldn't be found"); + + return "User (folder) couldn't be found"; + } + + try { + // Note: if we ever want to create share links for folders instead of files, just remove this try catch and only use setTarget, not setNodeId. + $file = $userFolder->get(path: $path); + } catch (NotFoundException $e) { + $this->logger->error("Can't create share link for $path because file doesn't exist"); + + return 'File not found at '.$path; + } + + try { + $share = $this->createShare([ + 'path' => $path, + 'file' => $file, + 'shareType' => $shareType, + 'permissions' => $permissions, + 'userId' => $userId + ]); + + return $this->getShareLink($share); + } catch (Exception $exception) { + $this->logger->error("Can't create share link for $path: " . $exception->getMessage()); + + throw new Exception('Can\'t create share link'); + } + } + + /** + * Creates a new folder in NextCloud, unless it already exists. + * + * @param string $folderPath Path (from root) to where you want to create a folder, include the name of the folder. (/Media/exampleFolder) + * + * @return bool True if successfully created a new folder. + * @throws Exception In case we can't create the folder because it is not permitted. + */ + public function createFolder(string $folderPath): bool + { + $folderPath = trim(string: $folderPath, characters: '/'); + + // Get the current user. + $currentUser = $this->userSession->getUser(); + $userFolder = $this->rootFolder->getUserFolder(userId: $currentUser ? $currentUser->getUID() : 'Guest'); + + // Check if folder exists and if not create it. + try { + try { + $userFolder->get(path: $folderPath); + } catch (NotFoundException $e) { + $userFolder->newFolder(path: $folderPath); + + return true; + } + + // Folder already exists. + $this->logger->info("This folder already exits $folderPath"); + return false; + + } catch (NotPermittedException $e) { + $this->logger->error("Can't create folder $folderPath: " . $e->getMessage()); + + throw new Exception("Can\'t create folder $folderPath"); + } + } + + /** + * Uploads a file to NextCloud. Will create a new file if it doesn't exist yet. + * + * @param mixed $content The content of the file. + * @param string $filePath Path (from root) where to save the file. NOTE: this should include the name and extension/format of the file as well! (example.pdf) + * + * @return bool True if successful. + * @throws Exception In case we can't write to file because it is not permitted. + */ + public function uploadFile(mixed $content, string $filePath): bool + { + $filePath = trim(string: $filePath, characters: '/'); + + // Get the current user. + $currentUser = $this->userSession->getUser(); + $userFolder = $this->rootFolder->getUserFolder(userId: $currentUser ? $currentUser->getUID() : 'Guest'); + + // Check if file exists and create it if not. + try { + try { + $userFolder->get(path: $filePath); + } catch (NotFoundException $e) { + $userFolder->newFile(path: $filePath); + $file = $userFolder->get(path: $filePath); + + $file->putContent(data: $content); + + return true; + } + + // File already exists. + $this->logger->warning("File $filePath already exists."); + return false; + + } catch (NotPermittedException|GenericFileException|LockedException $e) { + $this->logger->error("Can't create file $filePath: " . $e->getMessage()); + + throw new Exception("Can't write to file $filePath"); + } + } + + /** + * Overwrites an existing file in NextCloud. + * + * @param mixed $content The content of the file. + * @param string $filePath Path (from root) where to save the file. NOTE: this should include the name and extension/format of the file as well! (example.pdf) + * @param bool $createNew Default = false. If set to true this function will create a new file if it doesn't exist yet. + * + * @return bool True if successful. + * @throws Exception In case we can't write to file because it is not permitted. + */ + public function updateFile(mixed $content, string $filePath, bool $createNew = false): bool + { + $filePath = trim(string: $filePath, characters: '/'); + + // Get the current user. + $currentUser = $this->userSession->getUser(); + $userFolder = $this->rootFolder->getUserFolder(userId: $currentUser ? $currentUser->getUID() : 'Guest'); + + // Check if file exists and overwrite it if it does. + try { + try { + $file = $userFolder->get(path: $filePath); + + $file->putContent(data: $content); + + return true; + } catch (NotFoundException $e) { + if ($createNew === true) { + $userFolder->newFile(path: $filePath); + $file = $userFolder->get(path: $filePath); + + $file->putContent(data: $content); + + $this->logger->info("File $filePath did not exist, created a new file for it."); + return true; + } + } + + // File already exists. + $this->logger->warning("File $filePath already exists."); + return false; + + } catch (NotPermittedException|GenericFileException|LockedException $e) { + $this->logger->error("Can't create file $filePath: " . $e->getMessage()); + + throw new Exception("Can't write to file $filePath"); + } + } + + /** + * Deletes a file from NextCloud. + * + * @param string $filePath Path (from root) to the file you want to delete. + * + * @return bool True if successful. + * @throws Exception In case deleting the file is not permitted. + */ + public function deleteFile(string $filePath): bool + { + $filePath = trim(string: $filePath, characters: '/'); + + // Get the current user. + $currentUser = $this->userSession->getUser(); + $userFolder = $this->rootFolder->getUserFolder(userId: $currentUser ? $currentUser->getUID() : 'Guest'); + + // Check if file exists and delete it if it does. + try { + try { + $file = $userFolder->get(path: $filePath); + $file->delete(); + + return true; + } catch (NotFoundException $e) { + // File does not exist. + $this->logger->warning("File $filePath does not exist."); + + return false; + } + } catch (NotPermittedException|InvalidPathException $e) { + $this->logger->error("Can't delete file $filePath: " . $e->getMessage()); + + throw new Exception("Can't delete file $filePath"); + } + } + +} diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 2a097c1..a6f13bf 100755 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -2,6 +2,10 @@ namespace OCA\OpenRegister\Service; +use Adbar\Dot; +use Exception; +use GuzzleHttp\Exception\GuzzleException; +use InvalidArgumentException; use OC\URLGenerator; use OCA\OpenRegister\Db\Source; use OCA\OpenRegister\Db\SourceMapper; @@ -15,10 +19,13 @@ use OCA\OpenRegister\Db\AuditTrailMapper; use OCA\OpenRegister\Exception\ValidationException; use OCA\OpenRegister\Formats\BsnFormat; -use OCP\DB\Exception; +use OCP\App\IAppManager; use OCP\IURLGenerator; use Opis\JsonSchema\ValidationResult; use Opis\JsonSchema\Validator; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; use stdClass; use Symfony\Component\Uid\Uuid; use GuzzleHttp\Client; @@ -60,7 +67,10 @@ public function __construct( RegisterMapper $registerMapper, SchemaMapper $schemaMapper, AuditTrailMapper $auditTrailMapper, - private readonly IURLGenerator $urlGenerator + private ContainerInterface $container, + private readonly IURLGenerator $urlGenerator, + private readonly FileService $fileService, + private readonly IAppManager $appManager ) { $this->objectEntityMapper = $objectEntityMapper; @@ -69,6 +79,27 @@ public function __construct( $this->auditTrailMapper = $auditTrailMapper; } + /** + * Attempts to retrieve the OpenConnector service from the container. + * + * @return mixed|null The OpenConnector service if available, null otherwise. + * @throws ContainerExceptionInterface|NotFoundExceptionInterface + */ + public function getOpenConnector(string $filePath = '\Service\ObjectService'): mixed + { + if (in_array(needle: 'openconnector', haystack: $this->appManager->getInstalledApps()) === true) { + try { + // Attempt to get a OpenConnector file from the container + return $this->container->get("OCA\OpenConnector$filePath"); + } catch (Exception $e) { + // If the file is not available, return null + return null; + } + } + + return null; + } + /** * Validate an object with a schema. * If schema is not given and schemaObject is filled, the object will validate to the schemaObject. @@ -94,26 +125,34 @@ public function validateObject(array $object, ?int $schemaId = null, object $sch } /** - * Find an object by ID or UUID - * - * @param int|string $id The ID or UUID to search for - * @return ObjectEntity The found object - */ - public function find(int|string $id) { + * Find an object by ID or UUID + * + * @param int|string $id The ID or UUID to search for + * @param array|null $extend Properties to extend with related data + * + * @return ObjectEntity The found object + * @throws Exception + */ + public function find(int|string $id, ?array $extend = []): ObjectEntity + { return $this->getObject( register: $this->registerMapper->find($this->getRegister()), schema: $this->schemaMapper->find($this->getSchema()), - uuid: $id + uuid: $id, + extend: $extend ); } - /** - * Create a new object from array data - * - * @param array $object The object data - * @return ObjectEntity The created object - */ - public function createFromArray(array $object) { + /** + * Create a new object from array data + * + * @param array $object The object data + * + * @return ObjectEntity The created object + * @throws ValidationException + */ + public function createFromArray(array $object): ObjectEntity + { return $this->saveObject( register: $this->getRegister(), schema: $this->getSchema(), @@ -121,15 +160,18 @@ public function createFromArray(array $object) { ); } - /** - * Update an existing object from array data - * - * @param string $id The object ID to update - * @param array $object The new object data - * @param bool $updatedObject Whether this is an update operation - * @return ObjectEntity The updated object - */ - public function updateFromArray(string $id, array $object, bool $updatedObject, bool $patch = false) { + /** + * Update an existing object from array data + * + * @param string $id The object ID to update + * @param array $object The new object data + * @param bool $updatedObject Whether this is an update operation + * + * @return ObjectEntity The updated object + * @throws ValidationException + */ + public function updateFromArray(string $id, array $object, bool $updatedObject, bool $patch = false): ObjectEntity + { // Add ID to object data for update $object['id'] = $id; @@ -147,12 +189,14 @@ public function updateFromArray(string $id, array $object, bool $updatedObject, ); } - /** - * Delete an object - * - * @param array|\JsonSerializable $object The object to delete - * @return bool True if deletion was successful - */ + /** + * Delete an object + * + * @param array|\JsonSerializable $object The object to delete + * + * @return bool True if deletion was successful + * @throws Exception + */ public function delete(array|\JsonSerializable $object): bool { // Convert JsonSerializable objects to array @@ -167,17 +211,19 @@ public function delete(array|\JsonSerializable $object): bool ); } - /** - * Find all objects matching given criteria - * - * @param int|null $limit Maximum number of results - * @param int|null $offset Starting offset for pagination - * @param array $filters Filter criteria - * @param array $sort Sorting criteria - * @param string|null $search Search term - * @return array List of matching objects - */ - public function findAll(?int $limit = null, ?int $offset = null, array $filters = [], array $sort = [], ?string $search = null): array + /** + * Find all objects matching given criteria + * + * @param int|null $limit Maximum number of results + * @param int|null $offset Starting offset for pagination + * @param array $filters Filter criteria + * @param array $sort Sorting criteria + * @param string|null $search Search term + * @param array|null $extend Properties to extend with related data + * + * @return array List of matching objects + */ + public function findAll(?int $limit = null, ?int $offset = null, array $filters = [], array $sort = [], ?string $search = null, ?array $extend = []): array { $objects = $this->getObjects( register: $this->getRegister(), @@ -211,12 +257,14 @@ public function count(array $filters = [], ?string $search = null): int ->countAll(filters: $filters, search: $search); } - /** - * Find multiple objects by their IDs - * - * @param array $ids Array of object IDs to find - * @return array Array of found objects - */ + /** + * Find multiple objects by their IDs + * + * @param array $ids Array of object IDs to find + * + * @return array Array of found objects + * @throws Exception + */ public function findMultiple(array $ids): array { $result = []; @@ -232,6 +280,7 @@ public function findMultiple(array $ids): array * * @param array $filters Filter criteria * @param string|null $search Search term + * * @return array Aggregation results */ public function getAggregations(array $filters, ?string $search = null): array @@ -250,29 +299,35 @@ public function getAggregations(array $filters, ?string $search = null): array return []; } - /** - * Extract object data from an entity - * - * @param mixed $object The object to extract data from - * @return mixed The extracted object data - */ - private function getDataFromObject(mixed $object) { + /** + * Extract object data from an entity + * + * @param mixed $object The object to extract data from + * @param array|null $extend Properties to extend with related data + * + * @return mixed The extracted object data + */ + private function getDataFromObject(mixed $object, ?array $extend = []): mixed + { return $object->getObject(); } - /** - * Gets all objects of a specific type. - * - * @param string|null $objectType The type of objects to retrieve. - * @param int|null $register - * @param int|null $schema - * @param int|null $limit The maximum number of objects to retrieve. - * @param int|null $offset The offset from which to start retrieving objects. - * @param array $filters - * @return array The retrieved objects. - * @throws \Exception - */ - public function getObjects(?string $objectType = null, ?int $register = null, ?int $schema = null, ?int $limit = null, ?int $offset = null, array $filters = [], array $sort = [], ?string $search = null): array + /** + * Gets all objects of a specific type. + * + * @param string|null $objectType The type of objects to retrieve. + * @param int|null $register + * @param int|null $schema + * @param int|null $limit The maximum number of objects to retrieve. + * @param int|null $offset The offset from which to start retrieving objects. + * @param array $filters + * @param array $sort + * @param string|null $search + * @param array|null $extend Properties to extend with related data + * + * @return array The retrieved objects. + */ + public function getObjects(?string $objectType = null, ?int $register = null, ?int $schema = null, ?int $limit = null, ?int $offset = null, array $filters = [], array $sort = [], ?string $search = null, ?array $extend = []): array { // Set object type and filters if register and schema are provided if ($objectType === null && $register !== null && $schema !== null) { @@ -288,7 +343,7 @@ public function getObjects(?string $objectType = null, ?int $register = null, ?i return $mapper->findAll(limit: $limit, offset: $offset, filters: $filters, sort: $sort, search: $search); } - /** + /** * Save an object * * @param int $register The register to save the object to. @@ -301,10 +356,11 @@ public function getObjects(?string $objectType = null, ?int $register = null, ?i */ public function saveObject(int $register, int $schema, array $object): ObjectEntity { - // Convert register and schema to their respective objects if they are strings + // Convert register and schema to their respective objects if they are strings // @todo ??? if (is_string($register)) { $register = $this->registerMapper->find($register); } + if (is_string($schema)) { $schema = $this->schemaMapper->find($schema); } @@ -318,10 +374,10 @@ public function saveObject(int $register, int $schema, array $object): ObjectEnt ); } - $validationResult = $this->validateObject(object: $object, schemaId: $schema); +// $validationResult = $this->validateObject(object: $object, schemaId: $schema); // Create new entity if none exists - if ($objectEntity === null) { + if (isset($object['id']) === false || $objectEntity === null) { $objectEntity = new ObjectEntity(); $objectEntity->setRegister($register); $objectEntity->setSchema($schema); @@ -346,33 +402,299 @@ public function saveObject(int $register, int $schema, array $object): ObjectEnt $schemaObject = $this->schemaMapper->find($schema); - if ($objectEntity->getId() && ($schemaObject->getHardValidation() === false || $validationResult->isValid() === true)){ + + // Handle object properties that are either nested objects or files + if ($schemaObject->getProperties() !== null && is_array($schemaObject->getProperties())) { + $object = $this->handleObjectRelations($objectEntity, $object, $schemaObject->getProperties(), $register, $schema); + $objectEntity->setObject($object); + } + + $objectEntity->setUri($this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute('openregister.Objects.show', ['id' => $objectEntity->getUuid()]))); + + if ($objectEntity->getId()) {// && ($schemaObject->getHardValidation() === false || $validationResult->isValid() === true)){ $objectEntity = $this->objectEntityMapper->update($objectEntity); $this->auditTrailMapper->createAuditTrail(new: $objectEntity, old: $oldObject); - } else if ($schemaObject->getHardValidation() === false || $validationResult->isValid() === true) { + } else {//if ($schemaObject->getHardValidation() === false || $validationResult->isValid() === true) { $objectEntity = $this->objectEntityMapper->insert($objectEntity); $this->auditTrailMapper->createAuditTrail(new: $objectEntity); } - if ($validationResult->isValid() === false) { - throw new ValidationException(message: 'The object could not be validated', errors: $validationResult->error()); - } +// if ($validationResult->isValid() === false) { +// throw new ValidationException(message: 'The object could not be validated', errors: $validationResult->error()); +// } return $objectEntity; } + /** + * Handle object relations and file properties in schema properties and array items + * + * @param ObjectEntity $objectEntity The object entity to handle relations for + * @param array $object The object data + * @param array $properties The schema properties + * @param int $register The register ID + * @param int $schema The schema ID + * + * @return array Updated object data + * @throws Exception|ValidationException When file handling fails + */ + private function handleObjectRelations(ObjectEntity $objectEntity, array $object, array $properties, int $register, int $schema): array + { + + + foreach ($properties as $propertyName => $property) { + // Skip if property not in object + if (isset($object[$propertyName]) === false) { + continue; + } + + // Handle array type with items that may contain objects/files + if ($property['type'] === 'array' && isset($property['items']) === true) { + // Skip if not array in data + if (is_array($object[$propertyName]) === false) { + continue; + } + + // Process each array item + foreach ($object[$propertyName] as $index => $item) { + if ($property['items']['type'] === 'object') { + $subSchema = $schema; + + if(is_int($property['items']['$ref']) === true) { + $subSchema = $property['items']['$ref']; + } else if (filter_var(value: $property['items']['$ref'], filter: FILTER_VALIDATE_URL) !== false) { + $parsedUrl = parse_url($property['items']['$ref']); + $explodedPath = explode(separator: '/', string: $parsedUrl['path']); + $subSchema = end($explodedPath); + } + + if(is_array($item) === true) { + // Handle nested object in array + $nestedObject = $this->saveObject( + register: $register, + schema: $subSchema, + object: $item + ); + + // Store relation and replace with reference + $relations = $objectEntity->getRelations() ?? []; + $relations[$propertyName . '_' . $index] = $nestedObject->getUuid(); + $objectEntity->setRelations($relations); + $object[$propertyName][$index] = $nestedObject->getUuid(); + + } else { + $relations = $objectEntity->getRelations() ?? []; + $relations[$propertyName . '_' . $index] = $item; + $objectEntity->setRelations($relations); + } + + } else if ($property['items']['type'] === 'file') { + // Handle file in array + $object[$propertyName][$index] = $this->handleFileProperty( + objectEntity: $objectEntity, + object: [$propertyName => [$index => $item]], + propertyName: $propertyName . '.' . $index + )[$propertyName]; + } + } + } + // Handle single object type + else if ($property['type'] === 'object') { + + $subSchema = $schema; + + if(is_int($property['$ref']) === true) { + $subSchema = $property['$ref']; + } else if (filter_var(value: $property['$ref'], filter: FILTER_VALIDATE_URL) !== false) { + $parsedUrl = parse_url($property['$ref']); + $explodedPath = explode(separator: '/', string: $parsedUrl['path']); + $subSchema = end($explodedPath); + } + + if(is_array($object[$propertyName]) === true) { + $nestedObject = $this->saveObject( + register: $register, + schema: $subSchema, + object: $object[$propertyName] + ); + + // Store relation and replace with reference + $relations = $objectEntity->getRelations() ?? []; + $relations[$propertyName] = $nestedObject->getUuid(); + $objectEntity->setRelations($relations); + $object[$propertyName] = $nestedObject->getUuid(); + + } else { + $relations = $objectEntity->getRelations() ?? []; + $relations[$propertyName] = $object[$propertyName]; + $objectEntity->setRelations($relations); + } + + } + // Handle single file type + else if ($property['type'] === 'file') { + + $object = $this->handleFileProperty( + objectEntity: $objectEntity, + object: $object, + propertyName: $propertyName + ); + } + } + + return $object; + } + + /** + * Handle file property processing + * + * @param ObjectEntity $objectEntity The object entity + * @param array $object The object data + * @param string $propertyName The name of the file property + * + * @return array Updated object data + * @throws Exception|GuzzleException When file handling fails + */ + private function handleFileProperty(ObjectEntity $objectEntity, array $object, string $propertyName): array + { + $fileName = str_replace('.', '_', $propertyName); + $objectDot = new Dot($object); + + // Check if it's a Nextcloud file URL +// if (str_starts_with($object[$propertyName], $this->urlGenerator->getAbsoluteURL())) { +// $urlPath = parse_url($object[$propertyName], PHP_URL_PATH); +// if (preg_match('/\/f\/(\d+)/', $urlPath, $matches)) { +// $files = $objectEntity->getFiles() ?? []; +// $files[$propertyName] = (int)$matches[1]; +// $objectEntity->setFiles($files); +// $object[$propertyName] = (int)$matches[1]; +// return $object; +// } +// } + + // Handle base64 encoded file + if (is_string($objectDot->get($propertyName)) === true + && preg_match('/^data:([^;]*);base64,(.*)/', $objectDot->get($propertyName), $matches) + ) { + $fileContent = base64_decode($matches[2], true); + if ($fileContent === false) { + throw new Exception('Invalid base64 encoded file'); + } + } + // Handle URL file + else { + // Encode special characters in the URL + $encodedUrl = rawurlencode($objectDot->get("$propertyName.downloadUrl")); //@todo hardcoded .downloadUrl + + // Decode valid path separators and reserved characters + $encodedUrl = str_replace(['%2F', '%3A', '%28', '%29'], ['/', ':', '(', ')'], $encodedUrl); + + if (filter_var($encodedUrl, FILTER_VALIDATE_URL)) { + try { + // @todo hacky tacky + // Regular expression to get the filename and extension from url //@todo hardcoded .downloadUrl + if (preg_match("/\/([^\/]+)'\)\/\\\$value$/", $objectDot->get("$propertyName.downloadUrl"), $matches)) { + // @todo hardcoded way of getting the filename and extension from the url + $fileNameFromUrl = $matches[1]; + // @todo use only the extension from the url ? + // $fileName = $fileNameFromUrl; + $extension = substr(strrchr($fileNameFromUrl, '.'), 1); + $fileName = "$fileName.$extension"; + } + + if ($objectDot->has("$propertyName.source") === true) { + $sourceMapper = $this->getOpenConnector(filePath: '\Db\SourceMapper'); + $source = $sourceMapper->find($objectDot->get("$propertyName.source")); + + $callService = $this->getOpenConnector(filePath: '\Service\CallService'); + if ($callService === null) { + throw new Exception("OpenConnector service not available"); + } + $endpoint = str_replace($source->getLocation(), "", $encodedUrl); + + + $endpoint = urldecode($endpoint); + + $response = $callService->call(source: $source, endpoint: $endpoint, method: 'GET')->getResponse(); + + $fileContent = $response['body']; + + if( + $response['encoding'] === 'base64' + ) { + $fileContent = base64_decode(string: $fileContent); + } + + } else { + $client = new \GuzzleHttp\Client(); + $response = $client->get($encodedUrl); + $fileContent = $response->getBody()->getContents(); + } + } catch (Exception|NotFoundExceptionInterface $e) { + throw new Exception('Failed to download file from URL: ' . $e->getMessage()); + } + } else if (str_contains($objectDot->get($propertyName), $this->urlGenerator->getBaseUrl()) === true) { + return $object; + } else { + throw new Exception('Invalid file format - must be base64 encoded or valid URL'); + } + } + + 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"); + $filePath = "Objects/$schemaFolder/$objectFolder/$fileName"; + + $succes = $this->fileService->updateFile( + content: $fileContent, + filePath: $filePath, + createNew: true + ); + if ($succes === false) { + throw new Exception('Failed to upload this file: $filePath to NextCloud'); + } + + // Create or find ShareLink + $share = $this->fileService->findShare(path: $filePath); + if ($share !== null) { + $shareLink = $this->fileService->getShareLink($share).'/download'; + } else { + $shareLink = $this->fileService->createShareLink(path: $filePath).'/download'; + } + + $filesDot = new Dot($objectEntity->getFiles() ?? []); + $filesDot->set($propertyName, $shareLink); + $objectEntity->setFiles($filesDot->all()); + + // Preserve the original uri in the object 'json blob' + $objectDot = $objectDot->set($propertyName, $shareLink); + $object = $objectDot->all(); + } catch (Exception $e) { + throw new Exception('Failed to store file: ' . $e->getMessage()); + } + + return $object; + } + /** * Get an object * * @param Register $register The register to get the object from * @param Schema $schema The schema of the object * @param string $uuid The UUID of the object to get + * @param array $extend Properties to extend with related data * * @return ObjectEntity The resulting object - * @throws \Exception If source type is unsupported + * @throws Exception If source type is unsupported */ - public function getObject(Register $register, Schema $schema, string $uuid): ObjectEntity + public function getObject(Register $register, Schema $schema, string $uuid, ?array $extend = []): ObjectEntity { + // Handle internal source if ($register->getSource() === 'internal' || $register->getSource() === '') { return $this->objectEntityMapper->findByUuid($register, $schema, $uuid); @@ -380,7 +702,7 @@ public function getObject(Register $register, Schema $schema, string $uuid): Obj //@todo mongodb support - throw new \Exception('Unsupported source type'); + throw new Exception('Unsupported source type'); } /** @@ -391,7 +713,7 @@ public function getObject(Register $register, Schema $schema, string $uuid): Obj * @param string $uuid The UUID of the object to delete * * @return bool True if deletion was successful - * @throws \Exception If source type is unsupported + * @throws Exception If source type is unsupported */ public function deleteObject(Register $register, Schema $schema, string $uuid): bool { @@ -404,7 +726,7 @@ public function deleteObject(Register $register, Schema $schema, string $uuid): //@todo mongodb support - throw new \Exception('Unsupported source type'); + throw new Exception('Unsupported source type'); } /** @@ -414,9 +736,9 @@ public function deleteObject(Register $register, Schema $schema, string $uuid): * @param int|null $register Optional register ID * @param int|null $schema Optional schema ID * @return mixed The appropriate mapper - * @throws \InvalidArgumentException If unknown object type + * @throws InvalidArgumentException If unknown object type */ - public function getMapper(?string $objectType = null, ?int $register = null, ?int $schema = null) + public function getMapper(?string $objectType = null, ?int $register = null, ?int $schema = null): mixed { // Return self if register and schema provided if ($register !== null && $schema !== null) { @@ -434,7 +756,7 @@ public function getMapper(?string $objectType = null, ?int $register = null, ?in case 'objectEntity': return $this->objectEntityMapper; default: - throw new \InvalidArgumentException("Unknown object type: $objectType"); + throw new InvalidArgumentException("Unknown object type: $objectType"); } } @@ -444,10 +766,10 @@ public function getMapper(?string $objectType = null, ?int $register = null, ?in * @param string $objectType The type of objects to retrieve * @param array $ids The ids of the objects to retrieve * @return array The retrieved objects - * @throws \InvalidArgumentException If unknown object type + * @throws InvalidArgumentException If unknown object type */ - public function getMultipleObjects(string $objectType, array $ids) - { + public function getMultipleObjects(string $objectType, array $ids): array + { // Process the ids to handle different formats $processedIds = array_map(function($id) { if (is_object($id) && method_exists($id, 'getId')) { @@ -473,13 +795,58 @@ public function getMultipleObjects(string $objectType, array $ids) return $mapper->findMultiple($cleanedIds); } + /** + * Renders the entity by replacing the files and relations with their respective objects + * + * @param array $entity The entity to render + * @param array|null $extend Optional array of properties to extend, defaults to files and relations if not provided + * @return array The rendered entity with expanded files and relations + */ + public function renderEntity(array $entity, ?array $extend = []): array + { + // check if entity has files or relations and if not just return the entity + if (array_key_exists(key: 'files', array: $entity) === false && array_key_exists(key: 'relations', array: $entity) === false) { + return $entity; + } + + // Lets create a dot array of the entity + $dotEntity = new Dot($entity); + + // loop through the files and replace the file ids with the file objects) + if (array_key_exists(key: 'files', array: $entity) === true && empty($entity['files']) === false) { + // Loop through the files array where key is dot notation path and value is file id + foreach ($entity['files'] as $path => $fileId) { + // Replace the value at the dot notation path with the file URL + $dotEntity->set($path, $filesById[$fileId]->getUrl()); + } + } + + // Loop through the relations and replace the relation ids with the relation objects if extended + if (array_key_exists(key: 'relations', array: $entity) === true && empty($entity['relations']) === false) { + // loop through the relations and replace the relation ids with the relation objects + foreach ($entity['relations'] as $path => $relationId) { + // if the relation is not in the extend array, skip it + if (in_array(needle: $path, haystack: $extend) === false) { + continue; + } + // Replace the value at the dot notation path with the relation object + $dotEntity->set($path, $this->getObject(register: $this->getRegister(), schema: $this->getSchema(), uuid: $relationId)); + } + } + + // Update the entity with modified values + $entity = $dotEntity->all(); + + return $this->extendEntity(entity: $entity, extend: $extend); + } + /** * Extends an entity with related objects based on the extend array. * * @param mixed $entity The entity to extend * @param array $extend Properties to extend with related data * @return array The extended entity as an array - * @throws \Exception If property not found or no mapper available + * @throws Exception If property not found or no mapper available */ public function extendEntity(array $entity, array $extend): array { @@ -503,7 +870,7 @@ public function extendEntity(array $entity, array $extend): array } elseif (array_key_exists(key: $singularProperty, array: $result)) { $value = $result[$singularProperty]; } else { - throw new \Exception("Property '$property' or '$singularProperty' is not present in the entity."); + throw new Exception("Property '$property' or '$singularProperty' is not present in the entity."); } // Try to get mapper for property @@ -511,12 +878,12 @@ public function extendEntity(array $entity, array $extend): array try { $mapper = $this->getMapper(objectType: $property); $propertyObject = $singularProperty; - } catch (\Exception $e) { + } catch (Exception $e) { try { $mapper = $this->getMapper(objectType: $singularProperty); $propertyObject = $singularProperty; - } catch (\Exception $e) { - throw new \Exception("No mapper available for property '$property'."); + } catch (Exception $e) { + throw new Exception("No mapper available for property '$property'."); } } @@ -536,7 +903,7 @@ public function extendEntity(array $entity, array $extend): array * Get all registers extended with their schemas * * @return array The registers with schema data - * @throws \Exception If extension fails + * @throws Exception If extension fails */ public function getRegisters(): array {