From 8622efb6454d02416aeba9fd685ebffc27697a8a Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 8 Nov 2024 08:30:50 +0000 Subject: [PATCH 01/21] Bump version to 0.6.43 --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index eb985017..e9496ccd 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Create a [bug report](https://github.com/OpenCatalogi/.github/issues/new/choose) Create a [feature request](https://github.com/OpenCatalogi/.github/issues/new/choose) ]]> - 0.6.42 + 0.6.43 agpl Conduction Acato From cd08beeb906c616f69391767e971ccd35080b637 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 19 Nov 2024 15:59:15 +0100 Subject: [PATCH 02/21] hotfix/missinguse --- lib/Service/DirectoryService.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/Service/DirectoryService.php b/lib/Service/DirectoryService.php index adf295ac..fa3833a6 100644 --- a/lib/Service/DirectoryService.php +++ b/lib/Service/DirectoryService.php @@ -9,6 +9,7 @@ use OCA\OpenCatalogi\Db\CatalogMapper; use OCA\OpenCatalogi\Db\Listing; use OCA\OpenCatalogi\Db\ListingMapper; +use OCA\OpenCatalogi\Service\BroadcastService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\IAppConfig; @@ -39,6 +40,7 @@ class DirectoryService * @param ObjectService $objectService Object service for handling objects * @param CatalogMapper $catalogMapper Mapper for catalog objects * @param ListingMapper $listingMapper Mapper for listing objects + * @param BroadcastService $broadcastService Broadcast service for broadcasting */ public function __construct( private readonly IURLGenerator $urlGenerator, @@ -46,6 +48,7 @@ public function __construct( private readonly ObjectService $objectService, private readonly CatalogMapper $catalogMapper, private readonly ListingMapper $listingMapper, + private readonly BroadcastService $broadcastService ) { $this->client = new Client([]); From 3a5a78a5e8ea1ac180d168894a76f662a996dcc0 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 19 Nov 2024 15:00:43 +0000 Subject: [PATCH 03/21] Bump version to 0.6.44 --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index e9496ccd..f426c637 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Create a [bug report](https://github.com/OpenCatalogi/.github/issues/new/choose) Create a [feature request](https://github.com/OpenCatalogi/.github/issues/new/choose) ]]> - 0.6.43 + 0.6.44 agpl Conduction Acato From 222212b93ae5d8b3ac576a024242a26103dac5a5 Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Thu, 28 Nov 2024 14:32:57 +0100 Subject: [PATCH 04/21] Fix checking directory url before synchronizing --- lib/Controller/DirectoryController.php | 28 ++++----- lib/Controller/ListingsController.php | 16 ++++-- lib/Exception/DirectoryUrlException.php | 14 +++++ lib/Service/DirectoryService.php | 76 +++++++++++++++++++++---- 4 files changed, 99 insertions(+), 35 deletions(-) create mode 100644 lib/Exception/DirectoryUrlException.php diff --git a/lib/Controller/DirectoryController.php b/lib/Controller/DirectoryController.php index 0c456f15..d10e970c 100644 --- a/lib/Controller/DirectoryController.php +++ b/lib/Controller/DirectoryController.php @@ -6,6 +6,7 @@ use OCA\OpenCatalogi\Db\ListingMapper; use OCA\OpenCatalogi\Service\DirectoryService; use OCA\OpenCatalogi\Service\ObjectService; +use OCA\OpenCatalogi\Exception\DirectoryUrlException; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; @@ -75,25 +76,18 @@ public function update(): JSONResponse { // Get the URL from the request parameters $url = $this->request->getParam('directory'); - - // http://directory.opencatalogi.nl - // Check if the URL parameter is provided - if (empty($url) === true) { - return new JSONResponse(['error' => 'directory parameter is required'], 400); - } - - // Check if URL contains 'local' and throw exception if it does - if (str_contains(strtolower($url), 'local')) { - return new JSONResponse(['error' => 'Local URLs are not allowed'], 400); - } - - // Validate the URL - if (!filter_var($url, FILTER_VALIDATE_URL)) { - return new JSONResponse(['error' => 'Invalid URL provided'], 400); - } // Sync the external directory with the provided URL - $data = $this->directoryService->syncExternalDirectory($url); + try{ + $data = $this->directoryService->syncExternalDirectory($url); + } catch (DirectoryUrlException $exception) { + if($exception->getMessage() === 'URL is required') { + $exception->setMessage('Property "directory" is required'); + } + + + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: 400); + } // Return JSON response with the sync result return new JSONResponse($data); diff --git a/lib/Controller/ListingsController.php b/lib/Controller/ListingsController.php index ba24d12f..63b24bb5 100644 --- a/lib/Controller/ListingsController.php +++ b/lib/Controller/ListingsController.php @@ -6,6 +6,7 @@ use OCA\OpenCatalogi\Db\ListingMapper; use OCA\OpenCatalogi\Service\ObjectService; use OCA\OpenCatalogi\Service\DirectoryService; +use OCA\OpenCatalogi\Exception\DirectoryUrlException; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; @@ -186,13 +187,16 @@ public function add(): JSONResponse // Get the URL parameter from the request $url = $this->request->getParam('url'); - // Check if the URL parameter is provided - if (empty($url) === true) { - return new JSONResponse(['error' => 'URL parameter is required'], 400); - } - // Add the new listing using the provided URL - $result = $this->directoryService->syncExternalDirectory($url); + try{ + $result = $this->directoryService->syncExternalDirectory($url); + } catch (DirectoryUrlException $exception) { + if($exception->getMessage() === 'URL is required') { + $exception->setMessage('Property "url" is required'); + } + + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: 400); + } // Return the result as a JSON response return new JSONResponse(['success' => $result]); diff --git a/lib/Exception/DirectoryUrlException.php b/lib/Exception/DirectoryUrlException.php new file mode 100644 index 00000000..07e636d0 --- /dev/null +++ b/lib/Exception/DirectoryUrlException.php @@ -0,0 +1,14 @@ +message = $message; + } + +} diff --git a/lib/Service/DirectoryService.php b/lib/Service/DirectoryService.php index fa3833a6..fc27049b 100644 --- a/lib/Service/DirectoryService.php +++ b/lib/Service/DirectoryService.php @@ -4,18 +4,24 @@ use DateTime; use GuzzleHttp\Client; +use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Exception\ServerException; use OCA\OpenCatalogi\Db\Catalog; use OCA\OpenCatalogi\Db\CatalogMapper; use OCA\OpenCatalogi\Db\Listing; use OCA\OpenCatalogi\Db\ListingMapper; use OCA\OpenCatalogi\Service\BroadcastService; +use OCA\OpenCatalogi\Exception\DirectoryUrlException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Http\JSONResponse; use OCP\IAppConfig; use OCP\IURLGenerator; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; +use Symfony\Component\Routing\Generator\UrlGenerator; use Symfony\Component\Uid\Uuid; /** @@ -48,7 +54,7 @@ public function __construct( private readonly ObjectService $objectService, private readonly CatalogMapper $catalogMapper, private readonly ListingMapper $listingMapper, - private readonly BroadcastService $broadcastService + private readonly BroadcastService $broadcastService, ) { $this->client = new Client([]); @@ -228,16 +234,20 @@ public function doCronSync(): array { // Extract unique directory URLs // Get unique directories from listings $uniqueDirectories = array_unique(array_column($listings, 'directory')); - + // Add default OpenCatalogi directory if not already present $defaultDirectory = 'https://directory.opencatalogi.nl/apps/opencatalogi/api/directory'; if (!in_array($defaultDirectory, $uniqueDirectories)) { $uniqueDirectories[] = $defaultDirectory; - } + } // Sync each unique directory foreach ($uniqueDirectories as $directoryUrl) { - $result = $this->syncExternalDirectory($directoryUrl); + try { + $result = $this->syncExternalDirectory($directoryUrl); + } catch (DirectoryUrlException $exception) { + continue; + } $results = array_merge_recursive($results, $result); } @@ -269,7 +279,7 @@ public function validateExternalListing(array $listing): bool * @return array The updated listing * @throws DoesNotExistException|MultipleObjectsReturnedException|ContainerExceptionInterface|NotFoundExceptionInterface */ - public function updateListing(array $newListing, array $oldListing): array{ + public function updateListing(array $newListing, array $oldListing): array{ // Let's see if these changed by checking them against the hash $newHash = hash('sha256', json_encode($newListing)); $oldHash = hash('sha256', json_encode($oldListing)); @@ -283,7 +293,36 @@ public function updateListing(array $newListing, array $oldListing): array{ return $newListing->jsonSerialize(); } - + /** + * Checks if the URL complies to basic rules. + * + * @param string $url The url to check. + * @return void + * @throws DirectoryUrlException Thrown if the url is invalid. + */ + private function checkConditions(string $url): void + { + if (empty($url) === true) { + throw new DirectoryUrlException('URL is required'); + } + + // Check if URL contains the base url of this instance. + if (str_contains(haystack: strtolower($url), needle: $this->urlGenerator->getBaseUrl()) === true) { + throw new DirectoryUrlException('Cannot load current directory'); + } + + // Check if URL contains 'local' and throw exception if it does + if (str_contains(strtolower($url), 'local') === true) { + throw new DirectoryUrlException('Local urls are not allowed'); + } + + // Validate the URL + if (empty(filter_var($url, FILTER_VALIDATE_URL)) === false) { + throw new DirectoryUrlException('Invalid URL provided'); + } + } + + /** * Synchronize with an external directory * @@ -292,25 +331,32 @@ public function updateListing(array $newListing, array $oldListing): array{ * @return array An array containing synchronization results * @throws DoesNotExistException|MultipleObjectsReturnedException|ContainerExceptionInterface|NotFoundExceptionInterface * @throws GuzzleException|\OCP\DB\Exception + * @throws DirectoryUrlException */ public function syncExternalDirectory(string $url): array { // Log successful broadcast \OC::$server->getLogger()->info('Synchronizing directory with ' . $url); + $this->checkConditions($url); + try { + $checkUrls[] = $url; // Get the directory data $result = $this->client->get($url); // Fallback to the /api/directory endpoint if the result is not JSON if (str_contains($result->getHeader('Content-Type')[0], 'application/json') === false) { + + $checkUrls[] = $url.'/index.php/apps/opencatalogi/api/directory'; $url = rtrim($url, '/').'/apps/opencatalogi/api/directory'; $result = $this->client->get($url); + $checkUrls[] = $url; } - } catch (\GuzzleHttp\Exception\ClientException $e) { + } catch (ClientException|RequestException|ServerException $e) { // If we get a 404, the directory no longer exists if ($e->getResponse()->getStatusCode() === 404) { - // Delete all listings for this directory since it no longer exists + // Delete all listings for this directory since it no longer exists $this->deleteListingsByDirectory('listing', $url); throw new \Exception('Directory no longer exists at ' . $url); } @@ -324,10 +370,12 @@ public function syncExternalDirectory(string $url): array $currentListings = $this->objectService->getObjects( objectType: 'listing', filters: [ - 'directory'=>$url, + 'directory'=>$checkUrls, ] ); + + // Remove any listings without a catalog ID from the database foreach ($currentListings as $listing) { if (empty($listing['catalog'])) { @@ -342,11 +390,13 @@ public function syncExternalDirectory(string $url): array // array_column() with null as second parameter returns complete array entries // This will return complete listing objects indexed by their catalog ID $oldListings = array_column( - $currentListings, + $currentListings, null, // null returns complete array entries rather than a specific column 'catalog' // Index by catalog ID ); + var_dump($oldListings, $currentListings); + // Initialize arrays to store results $addedListings = []; $updatedListings = []; @@ -365,10 +415,12 @@ public function syncExternalDirectory(string $url): array // Check if we already have this listing by looking up its catalog ID in the oldListings array $oldListing = $oldListings[$listing['id']] ?? null; + var_dump($listing['id'], $oldListing, $oldListings); + // If no existing listing found, prepare the new listing data if ($oldListing === null) { $listing['hash'] = hash('sha256', json_encode($listing)); - $listing['catalog'] = $listing['id']; + $listing['catalog'] = $listing['id']; unset($listing['id']); } else { // Update existing listing @@ -420,7 +472,7 @@ private function deleteListingsByDirectory(string $directoryUrl): void { ] ); // Delete all listings - foreach ($currentListings as $listing) { + foreach ($currentListings as $listing) { $this->objectService->deleteObject('listing', $listing['id']); } } From 991a3da51a317522a42b3e687c0181ecfa3b45cd Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Thu, 28 Nov 2024 14:33:09 +0100 Subject: [PATCH 05/21] Use updates as patch --- lib/Service/ObjectService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index f0688c0b..298fdc91 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -302,7 +302,7 @@ public function saveObject(string $objectType, array $object, bool $updateVersio $mapper = $this->getMapper($objectType); // If the object has an id, update it; otherwise, create a new object if (isset($object['id']) === true) { - return $mapper->updateFromArray($object['id'], $object, $updateVersion); + return $mapper->updateFromArray($object['id'], $object, $updateVersion, patch: true); } else { return $mapper->createFromArray($object); From 6b29788acb2a0d72965d5a9dcb49b092adb91157 Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Thu, 28 Nov 2024 14:45:57 +0100 Subject: [PATCH 06/21] remove var_dumps --- lib/Service/DirectoryService.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/Service/DirectoryService.php b/lib/Service/DirectoryService.php index fc27049b..4f5ae535 100644 --- a/lib/Service/DirectoryService.php +++ b/lib/Service/DirectoryService.php @@ -46,7 +46,7 @@ class DirectoryService * @param ObjectService $objectService Object service for handling objects * @param CatalogMapper $catalogMapper Mapper for catalog objects * @param ListingMapper $listingMapper Mapper for listing objects - * @param BroadcastService $broadcastService Broadcast service for broadcasting + * @param BroadcastService $broadcastService Broadcast service for broadcasting */ public function __construct( private readonly IURLGenerator $urlGenerator, @@ -395,8 +395,6 @@ public function syncExternalDirectory(string $url): array 'catalog' // Index by catalog ID ); - var_dump($oldListings, $currentListings); - // Initialize arrays to store results $addedListings = []; $updatedListings = []; @@ -415,8 +413,6 @@ public function syncExternalDirectory(string $url): array // Check if we already have this listing by looking up its catalog ID in the oldListings array $oldListing = $oldListings[$listing['id']] ?? null; - var_dump($listing['id'], $oldListing, $oldListings); - // If no existing listing found, prepare the new listing data if ($oldListing === null) { $listing['hash'] = hash('sha256', json_encode($listing)); From 11bfadd6b9213951557fd4256f03af170c69e5ea Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Thu, 28 Nov 2024 14:51:50 +0100 Subject: [PATCH 07/21] Styling and making mappers compatible with being called with $patch --- lib/Controller/DirectoryController.php | 2 +- lib/Controller/ListingsController.php | 2 +- lib/Db/AttachmentMapper.php | 2 +- lib/Db/CatalogMapper.php | 4 ++-- lib/Db/ListingMapper.php | 2 +- lib/Db/OrganizationMapper.php | 2 +- lib/Db/PublicationMapper.php | 2 +- lib/Db/PublicationTypeMapper.php | 2 +- lib/Db/ThemeMapper.php | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/Controller/DirectoryController.php b/lib/Controller/DirectoryController.php index d10e970c..211bf86f 100644 --- a/lib/Controller/DirectoryController.php +++ b/lib/Controller/DirectoryController.php @@ -78,7 +78,7 @@ public function update(): JSONResponse $url = $this->request->getParam('directory'); // Sync the external directory with the provided URL - try{ + try { $data = $this->directoryService->syncExternalDirectory($url); } catch (DirectoryUrlException $exception) { if($exception->getMessage() === 'URL is required') { diff --git a/lib/Controller/ListingsController.php b/lib/Controller/ListingsController.php index 63b24bb5..450a6166 100644 --- a/lib/Controller/ListingsController.php +++ b/lib/Controller/ListingsController.php @@ -188,7 +188,7 @@ public function add(): JSONResponse $url = $this->request->getParam('url'); // Add the new listing using the provided URL - try{ + try { $result = $this->directoryService->syncExternalDirectory($url); } catch (DirectoryUrlException $exception) { if($exception->getMessage() === 'URL is required') { diff --git a/lib/Db/AttachmentMapper.php b/lib/Db/AttachmentMapper.php index f5eec661..0832eaaf 100644 --- a/lib/Db/AttachmentMapper.php +++ b/lib/Db/AttachmentMapper.php @@ -126,7 +126,7 @@ public function createFromArray(array $object): Attachment * @throws DoesNotExistException If the entity is not found * @throws MultipleObjectsReturnedException|\OCP\DB\Exception If multiple entities are found */ - public function updateFromArray(int $id, array $object, bool $updateVersion = true): Attachment + public function updateFromArray(int $id, array $object, bool $updateVersion = true, bool $patch = false): Attachment { $attachment = $this->find($id); // Fallback to create if the attachment does not exist diff --git a/lib/Db/CatalogMapper.php b/lib/Db/CatalogMapper.php index 825597de..6bb66f91 100644 --- a/lib/Db/CatalogMapper.php +++ b/lib/Db/CatalogMapper.php @@ -140,9 +140,9 @@ public function createFromArray(array $object): Catalog * * @return Catalog The updated Catalog entity */ - public function updateFromArray(int $id, array $object, bool $updateVersion = true): Catalog + public function updateFromArray(int $id, array $object, bool $updateVersion = true, bool $patch = false): Catalog { - $catalog = $this->find($id); + $catalog = $this->find($id); // Fallback to create if the catalog does not exist if ($catalog === null) { $object['uuid'] = $id; diff --git a/lib/Db/ListingMapper.php b/lib/Db/ListingMapper.php index 21a4300d..05ddab36 100644 --- a/lib/Db/ListingMapper.php +++ b/lib/Db/ListingMapper.php @@ -234,7 +234,7 @@ public function createFromArray(array $object): Listing * @return Listing The updated Listing entity * @throws Exception */ - public function updateFromArray(int|string $id, array $object, bool $updateVersion = true): Listing + public function updateFromArray(int|string $id, array $object, bool $updateVersion = true, bool $patch = false): Listing { $listing = $this->find($id); // Fallback to create if the listing does not exist diff --git a/lib/Db/OrganizationMapper.php b/lib/Db/OrganizationMapper.php index c83a859c..3c605d4c 100644 --- a/lib/Db/OrganizationMapper.php +++ b/lib/Db/OrganizationMapper.php @@ -135,7 +135,7 @@ public function createFromArray(array $object): Organization * * @return Organization The updated Organization entity */ - public function updateFromArray(int $id, array $object, bool $updateVersion = true): Organization + public function updateFromArray(int $id, array $object, bool $updateVersion = true, bool $patch = false): Organization { $organization = $this->find($id); // Fallback to create if the organization does not exist diff --git a/lib/Db/PublicationMapper.php b/lib/Db/PublicationMapper.php index c3ced4f0..56363f96 100644 --- a/lib/Db/PublicationMapper.php +++ b/lib/Db/PublicationMapper.php @@ -224,7 +224,7 @@ public function createFromArray(array $object): Publication * * @return Publication The updated Publication entity */ - public function updateFromArray(int $id, array $object, bool $updateVersion = true): Publication + public function updateFromArray(int $id, array $object, bool $updateVersion = true, bool $patch = false): Publication { $publication = $this->find(id: $id); // Fallback to create if the publication does not exist diff --git a/lib/Db/PublicationTypeMapper.php b/lib/Db/PublicationTypeMapper.php index 7a2cc336..1474130d 100644 --- a/lib/Db/PublicationTypeMapper.php +++ b/lib/Db/PublicationTypeMapper.php @@ -155,7 +155,7 @@ public function createFromArray(array $object): PublicationType * @throws DoesNotExistException If the entity is not found * @throws MultipleObjectsReturnedException|\OCP\DB\Exception If multiple entities are found */ - public function updateFromArray(int $id, array $object, bool $updateVersion = true): PublicationType + public function updateFromArray(int $id, array $object, bool $updateVersion = true, bool $patch = false): PublicationType { $publicationType = $this->find($id); // Fallback to create if the publication type does not exist diff --git a/lib/Db/ThemeMapper.php b/lib/Db/ThemeMapper.php index 272159ea..8b0b239b 100644 --- a/lib/Db/ThemeMapper.php +++ b/lib/Db/ThemeMapper.php @@ -141,7 +141,7 @@ public function createFromArray(array $object): Theme * @throws DoesNotExistException If the entity is not found * @throws MultipleObjectsReturnedException|\OCP\DB\Exception If multiple entities are found */ - public function updateFromArray(int $id, array $object, bool $updateVersion = true): Theme + public function updateFromArray(int $id, array $object, bool $updateVersion = true, bool $patch = false): Theme { $theme = $this->find($id); // Fallback to create if the theme does not exist From e16860f14f40145b879563e5adfcdaec85c8cc8b Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Thu, 28 Nov 2024 15:00:38 +0100 Subject: [PATCH 08/21] remove newline --- lib/Controller/DirectoryController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/Controller/DirectoryController.php b/lib/Controller/DirectoryController.php index 211bf86f..4b6218db 100644 --- a/lib/Controller/DirectoryController.php +++ b/lib/Controller/DirectoryController.php @@ -85,7 +85,6 @@ public function update(): JSONResponse $exception->setMessage('Property "directory" is required'); } - return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: 400); } From 43b50f59295f3836aa01e6b851adca4642d028e1 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 28 Nov 2024 14:01:19 +0000 Subject: [PATCH 09/21] Bump version to 0.6.45 --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index f426c637..61301da6 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Create a [bug report](https://github.com/OpenCatalogi/.github/issues/new/choose) Create a [feature request](https://github.com/OpenCatalogi/.github/issues/new/choose) ]]> - 0.6.44 + 0.6.45 agpl Conduction Acato From 79308e9d3afd3aeca21d68764460ad8ecffc26c2 Mon Sep 17 00:00:00 2001 From: Remko Huisman <43807324+remko48@users.noreply.github.com> Date: Thu, 21 Nov 2024 16:55:41 +0100 Subject: [PATCH 10/21] added workflows added workflows --- .../pull-request-from-branch-check.yaml | 18 +++++++++++++++++ .../workflows/pull-request-lint-check.yaml | 20 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 .github/workflows/pull-request-from-branch-check.yaml create mode 100644 .github/workflows/pull-request-lint-check.yaml diff --git a/.github/workflows/pull-request-from-branch-check.yaml b/.github/workflows/pull-request-from-branch-check.yaml new file mode 100644 index 00000000..a40f09fa --- /dev/null +++ b/.github/workflows/pull-request-from-branch-check.yaml @@ -0,0 +1,18 @@ +name: Main Branch Protection + +on: + pull_request: + branches: + - master + +jobs: + check-branch: + runs-on: ubuntu-latest + steps: + - name: Check branch + run: | + if [[ ${GITHUB_HEAD_REF} != development ]] && [[ ${GITHUB_HEAD_REF} != documentation ]] && ! [[ ${GITHUB_HEAD_REF} =~ ^hotfix/ ]]; + then + echo "Error: Pull request must come from 'development', 'documentation' or 'hotfix/' branch" + exit 1 + fi diff --git a/.github/workflows/pull-request-lint-check.yaml b/.github/workflows/pull-request-lint-check.yaml new file mode 100644 index 00000000..0c04e87a --- /dev/null +++ b/.github/workflows/pull-request-lint-check.yaml @@ -0,0 +1,20 @@ +name: Lint Check + +on: + pull_request: + branches: + - never + +jobs: + lint-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Install dependencies + run: npm i + + - name: Linting + run: npm run lint From e17b798f200823a570f103e0c4bc189b6e3eb3ae Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Thu, 28 Nov 2024 16:01:25 +0100 Subject: [PATCH 11/21] Correct check --- lib/Service/DirectoryService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Service/DirectoryService.php b/lib/Service/DirectoryService.php index 4f5ae535..09286299 100644 --- a/lib/Service/DirectoryService.php +++ b/lib/Service/DirectoryService.php @@ -317,7 +317,7 @@ private function checkConditions(string $url): void } // Validate the URL - if (empty(filter_var($url, FILTER_VALIDATE_URL)) === false) { + if (filter_var($url, FILTER_VALIDATE_URL) === false) { throw new DirectoryUrlException('Invalid URL provided'); } } From 26f60a0a8f688a2b12e28abd33fefb4a59c26b95 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 28 Nov 2024 15:01:55 +0000 Subject: [PATCH 12/21] Bump version to 0.6.46 --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 61301da6..eefd962d 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Create a [bug report](https://github.com/OpenCatalogi/.github/issues/new/choose) Create a [feature request](https://github.com/OpenCatalogi/.github/issues/new/choose) ]]> - 0.6.45 + 0.6.46 agpl Conduction Acato From 2ab3db9218063a08aeea2d5e7999de5dae9efd8b Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 28 Nov 2024 15:02:32 +0000 Subject: [PATCH 13/21] Bump version to 0.6.47 --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index eefd962d..be81d384 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Create a [bug report](https://github.com/OpenCatalogi/.github/issues/new/choose) Create a [feature request](https://github.com/OpenCatalogi/.github/issues/new/choose) ]]> - 0.6.46 + 0.6.47 agpl Conduction Acato From fc43d0ed4d14bd70cbdbb3c35cfab4d7b3685186 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 29 Nov 2024 15:16:49 +0100 Subject: [PATCH 14/21] Add pages to open catalogi --- appinfo/routes.php | 5 +- lib/Controller/PagesController | 145 +++++++++++++ lib/Controller/SettingsController.php | 7 +- lib/Db/Page.php | 112 ++++++++++ lib/Db/PageMapper.php | 150 ++++++++++++++ lib/Migration/Version6Date20241129151236.php | 74 +++++++ lib/Service/ObjectService.php | 4 + src/entities/index.js | 1 + src/entities/page/index.js | 4 + src/entities/page/page.mock.ts | 43 ++++ src/entities/page/page.spec.ts | 47 +++++ src/entities/page/page.ts | 74 +++++++ src/entities/page/page.types.ts | 13 ++ src/navigation/MainMenu.vue | 7 + src/store/modules/page.spec.ts | 53 +++++ src/store/modules/page.ts | 160 +++++++++++++++ src/store/store.js | 3 + src/views/Views.vue | 3 + src/views/pages/PageDetail.vue | 202 +++++++++++++++++++ src/views/pages/PageIndex.vue | 52 +++++ src/views/pages/PageList.vue | 202 +++++++++++++++++++ 21 files changed, 1358 insertions(+), 3 deletions(-) create mode 100644 lib/Controller/PagesController create mode 100644 lib/Db/Page.php create mode 100644 lib/Db/PageMapper.php create mode 100644 lib/Migration/Version6Date20241129151236.php create mode 100644 src/entities/page/index.js create mode 100644 src/entities/page/page.mock.ts create mode 100644 src/entities/page/page.spec.ts create mode 100644 src/entities/page/page.ts create mode 100644 src/entities/page/page.types.ts create mode 100644 src/store/modules/page.spec.ts create mode 100644 src/store/modules/page.ts create mode 100644 src/views/pages/PageDetail.vue create mode 100644 src/views/pages/PageIndex.vue create mode 100644 src/views/pages/PageList.vue diff --git a/appinfo/routes.php b/appinfo/routes.php index 427b3af4..311f82ae 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -6,6 +6,7 @@ 'publications' => ['url' => '/api/publications'], 'organizations' => ['url' => '/api/organizations'], 'themes' => ['url' => '/api/themes'], + 'pages' => ['url' => '/api/pages'], 'attachments' => ['url' => '/api/attachments'], 'catalogi' => ['url' => '/api/catalogi'], 'listings' => ['url' => '/api/listings'], @@ -37,6 +38,8 @@ ['name' => 'search#publication', 'url' => '/api/search/publications/{publicationId}', 'verb' => 'GET', 'requirements' => ['publicationId' => '[^/]+']], ['name' => 'search#attachments', 'url' => '/api/search/publications/{publicationId}/attachments', 'verb' => 'GET', 'requirements' => ['publicationId' => '[^/]+']], ['name' => 'search#themes', 'url' => '/api/search/themes', 'verb' => 'GET'], - ['name' => 'search#theme', 'url' => '/api/search/themes/{themeId}', 'verb' => 'GET', 'requirements' => ['themeId' => '\d+']] + ['name' => 'search#theme', 'url' => '/api/search/themes/{themeId}', 'verb' => 'GET', 'requirements' => ['themeId' => '\d+']], + ['name' => 'search#pages', 'url' => '/api/search/pages', 'verb' => 'GET'], + ['name' => 'search#page', 'url' => '/api/search/pages/{pageSlug}', 'verb' => 'GET', 'requirements' => ['pageId' => '[^/]+']] ] ]; diff --git a/lib/Controller/PagesController b/lib/Controller/PagesController new file mode 100644 index 00000000..d10ef7d5 --- /dev/null +++ b/lib/Controller/PagesController @@ -0,0 +1,145 @@ +request->getParams(); + + // Fetch page objects based on filters and order + $data = $this->objectService->getResultArrayForRequest('page', $requestParams); + + // Return JSON response + return new JSONResponse($data); + } + + /** + * Retrieve a specific page by its ID. + * + * @param string|int $id The ID of the page to retrieve + * @return JSONResponse JSON response containing the requested page + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function show(string|int $id): JSONResponse + { + // Fetch the page object by its ID + $object = $this->objectService->getObject('page', $id); + + // Return the page as a JSON response + return new JSONResponse($object); + } + + /** + * Create a new page. + * + * @return JSONResponse The response containing the created page object. + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function create(): JSONResponse + { + // Get all parameters from the request + $data = $this->request->getParams(); + + // Remove the 'id' field if it exists, as we're creating a new object + unset($data['id']); + + // Save the new page object + $object = $this->objectService->saveObject('page', $data); + + // Return the created object as a JSON response + return new JSONResponse($object); + } + + /** + * Update an existing page. + * + * @param string|int $id The ID of the page to update. + * @return JSONResponse The response containing the updated page object. + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function update(string|int $id): JSONResponse + { + // Get all parameters from the request + $data = $this->request->getParams(); + + // Ensure the ID in the data matches the ID in the URL + $data['id'] = $id; + + // Save the updated page object + $object = $this->objectService->saveObject('page', $data); + + // Return the updated object as a JSON response + return new JSONResponse($object); + } + + /** + * Delete a page. + * + * @param string|int $id The ID of the page to delete. + * @return JSONResponse The response indicating the result of the deletion. + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function destroy(string|int $id): JSONResponse + { + // Delete the page object + $result = $this->objectService->deleteObject('page', $id); + + // Return the result as a JSON response + return new JSONResponse(['success' => $result], $result === true ? '200' : '404'); + } +} diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 8da08328..5d975be3 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -45,7 +45,7 @@ public function index(): JSONResponse { // Initialize the data array $data = []; - $data['objectTypes'] = ['attachment', 'catalog', 'listing', 'publicationtype', 'organization', 'publication', 'theme']; + $data['objectTypes'] = ['attachment', 'catalog', 'listing', 'publicationtype', 'organization', 'publication', 'theme', 'page']; $data['openRegisters'] = false; $data['availableRegisters'] = []; @@ -78,7 +78,10 @@ public function index(): JSONResponse 'publication_register' => '', 'theme_source' => 'internal', 'theme_schema' => '', - 'theme_register' => '' + 'theme_register' => '', + 'page_source' => 'internal', + 'page_schema' => '', + 'page_register' => '', ]; // Get the current values for the object types from the configuration diff --git a/lib/Db/Page.php b/lib/Db/Page.php new file mode 100644 index 00000000..30f6020f --- /dev/null +++ b/lib/Db/Page.php @@ -0,0 +1,112 @@ +addType(fieldName: 'uuid', type: 'string'); + $this->addType(fieldName: 'name', type: 'string'); + $this->addType(fieldName: 'slug', type: 'string'); + $this->addType(fieldName: 'contents', type: 'json'); + $this->addType(fieldName: 'created', type: 'datetime'); + $this->addType(fieldName: 'updated', type: 'datetime'); + } + + /** + * Get array of JSON field names + * + * @return array List of field names that are JSON type + */ + public function getJsonFields(): array + { + return array_keys( + array_filter($this->getFieldTypes(), function ($field) { + return $field === 'json'; + }) + ); + } + + /** + * Hydrate the entity from an array of data + * + * @param array $object Data to hydrate from + * @return self + */ + public function hydrate(array $object): self + { + $jsonFields = $this->getJsonFields(); + + // Remove any fields that start with an underscore + // These are typically internal fields that shouldn't be updated directly + foreach ($object as $key => $value) { + if (str_starts_with($key, '_')) { + unset($object[$key]); + } + } + + foreach ($object as $key => $value) { + if (in_array($key, $jsonFields) === true && $value === []) { + $value = null; + } + + $method = 'set'.ucfirst($key); + + try { + $this->$method($value); + } catch (\Exception $exception) { + } + } + + return $this; + } + + /** + * Serialize the entity to JSON + * + * @return array Serialized data + */ + public function jsonSerialize(): array + { + $array = [ + 'id' => $this->id, + 'uuid' => $this->uuid, + 'name' => $this->name, + 'slug' => $this->slug, + 'contents' => $this->contents, + 'created_at' => $this->created?->format('c'), + 'updated_at' => $this->updated?->format('c') + ]; + + $jsonFields = $this->getJsonFields(); + + foreach ($array as $key => $value) { + if (in_array($key, $jsonFields) === true && $value === null) { + $array[$key] = []; + } + } + + return $array; + } +} diff --git a/lib/Db/PageMapper.php b/lib/Db/PageMapper.php new file mode 100644 index 00000000..03bcb026 --- /dev/null +++ b/lib/Db/PageMapper.php @@ -0,0 +1,150 @@ +db->getQueryBuilder(); + + $qb->select('*') + ->from('ocat_pages') + ->where($qb->expr()->orX( + $qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)), + $qb->expr()->eq('uuid', $qb->createNamedParameter($id, IQueryBuilder::PARAM_STR)) + )); + + try { + return $this->findEntity($qb); + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { + return null; + } + } + + /** + * Find multiple Pages by their IDs or UUIDs + * + * @param array $ids An array of IDs or UUIDs + * @return array An array of found Page entities + */ + public function findMultiple(array $ids): array + { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from('ocat_pages') + ->where($qb->expr()->orX( + $qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)), + $qb->expr()->in('uuid', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_STR_ARRAY)) + )); + + return $this->findEntities(query: $qb); + } + + /** + * Find all Pages with optional limit and offset + * + * @param int|null $limit Maximum number of results to return + * @param int|null $offset Number of results to skip + * @return array An array of all found Page entities + */ + public function findAll(int $limit = null, int $offset = null, array $filters = [], array $sort = [], ?string $search = null): array + { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from('ocat_pages') + ->setMaxResults($limit) + ->setFirstResult($offset); + + return $this->findEntities(query: $qb); + } + + /** + * Create a new Page from an array of data + * + * @param array $object An array of Page data + * @return Page The newly created Page entity + */ + public function createFromArray(array $object): Page + { + $page = new Page(); + $page->hydrate(object: $object); + + // Set uuid if not provided + if ($page->getUuid() === null) { + $page->setUuid(Uuid::v4()); + } + + // Generate slug from name if not provided + if ($page->getSlug() === null && $page->getName() !== null) { + // Convert to lowercase and replace spaces with dashes + $slug = strtolower($page->getName()); + $slug = preg_replace('/[^a-z0-9-]/', '-', $slug); + $slug = preg_replace('/-+/', '-', $slug); + $slug = trim($slug, '-'); + $page->setSlug($slug); + } + + return $this->insert(entity: $page); + } + + /** + * Update an existing Page from an array of data + * + * @param int $id The ID of the Page to update + * @param array $object An array of updated Page data + * @return Page The updated Page entity + * @throws DoesNotExistException If the entity is not found + * @throws MultipleObjectsReturnedException|\OCP\DB\Exception If multiple entities are found + */ + public function updateFromArray(int $id, array $object): Page + { + $page = $this->find($id); + // Fallback to create if the page does not exist + if ($page === null) { + $object['uuid'] = $id; + return $this->createFromArray($object); + } + + $page->hydrate($object); + + return $this->update($page); + } +} diff --git a/lib/Migration/Version6Date20241129151236.php b/lib/Migration/Version6Date20241129151236.php new file mode 100644 index 00000000..16192cb8 --- /dev/null +++ b/lib/Migration/Version6Date20241129151236.php @@ -0,0 +1,74 @@ +hasTable(tableName: 'ocat_pages') === false) { + $table = $schema->createTable(tableName: 'ocat_pages'); + + // Primary key and identifier columns + $table->addColumn(name: 'id', typeName: Types::BIGINT, options: ['autoincrement' => true, 'notnull' => true, 'length' => 4]); + $table->addColumn(name: 'uuid', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255]); + $table->addColumn(name: 'version', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255, 'default' => '0.0.1']); + + // Meta columns + $table->addColumn(name: 'name', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255]); + $table->addColumn(name: 'slug', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255]); + $table->addColumn(name: 'contents', typeName: Types::JSON, options: ['notnull' => false]); + $table->addColumn(name: 'updated', typeName: Types::DATETIME, options: ['notnull' => true, 'default' => 'CURRENT_TIMESTAMP']); + $table->addColumn(name: 'created', typeName: Types::DATETIME, options: ['notnull' => true, 'default' => 'CURRENT_TIMESTAMP']); + + // Keys and indexes + $table->setPrimaryKey(columnNames: ['id']); + $table->addIndex(['uuid'], 'ocat_pages_uuid_index'); + $table->addIndex(['slug'], 'ocat_pages_slug_index'); + } + + 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/ObjectService.php b/lib/Service/ObjectService.php index e0befbbf..e223d22f 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -20,6 +20,7 @@ use OCP\IAppConfig; // Import mappers use OCA\OpenCatalogi\Db\AttachmentMapper; +use OCA\OpenCatalogi\Db\PageMapper; use OCA\OpenCatalogi\Db\CatalogMapper; use OCA\OpenCatalogi\Db\ListingMapper; use OCA\OpenCatalogi\Db\PublicationTypeMapper; @@ -47,6 +48,7 @@ class ObjectService * @param OrganizationMapper $organizationMapper Mapper for organizations * @param PublicationMapper $publicationMapper Mapper for publications * @param ThemeMapper $themeMapper Mapper for themes + * @param PageMapper $pageMapper Mapper for pages * @param ContainerInterface $container Container for dependency injection * @param IAppManager $appManager App manager interface * @param IAppConfig $config App configuration interface @@ -59,6 +61,7 @@ public function __construct( private OrganizationMapper $organizationMapper, private PublicationMapper $publicationMapper, private ThemeMapper $themeMapper, + private PageMapper $pageMapper, private ContainerInterface $container, private readonly IAppManager $appManager, private readonly IAppConfig $config, @@ -112,6 +115,7 @@ private function getMapper(string $objectType): mixed 'organization' => $this->organizationMapper, 'publication' => $this->publicationMapper, 'theme' => $this->themeMapper, + 'page' => $this->pageMapper, default => throw new InvalidArgumentException("Unknown object type: $objectType"), }; } diff --git a/src/entities/index.js b/src/entities/index.js index 2f4bb056..b5726d33 100644 --- a/src/entities/index.js +++ b/src/entities/index.js @@ -7,3 +7,4 @@ export * from './organization/index.js' export * from './publication/index.js' export * from './theme/index.js' export * from './publicationType/index.js' +export * from './page/index.js' \ No newline at end of file diff --git a/src/entities/page/index.js b/src/entities/page/index.js new file mode 100644 index 00000000..b496e408 --- /dev/null +++ b/src/entities/page/index.js @@ -0,0 +1,4 @@ +export * from './page.ts' +export * from './page.types.ts' +export * from './page.mock.ts' + diff --git a/src/entities/page/page.mock.ts b/src/entities/page/page.mock.ts new file mode 100644 index 00000000..90e5e8f5 --- /dev/null +++ b/src/entities/page/page.mock.ts @@ -0,0 +1,43 @@ +import { Page } from './page' +import { TPage } from './page.types' + +/** + * Mock data function that returns an array of page data objects + * Used for testing and development purposes + */ +export const mockPageData = (): TPage[] => [ + { // full data + id: '1', + uuid: '123e4567-e89b-12d3-a456-426614174000', + name: 'Test Page', + slug: 'test-page', + contents: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }, + // @ts-expect-error -- expected missing contents + { // partial data + id: '2', + uuid: '123e4567-e89b-12d3-a456-426614174001', + name: 'Another Page', + slug: 'another-page', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }, + { // invalid data + id: '3', + uuid: '123e4567-e89b-12d3-a456-426614174002', + name: '', + slug: '', + contents: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }, +] + +/** + * Creates an array of Page instances from provided data or default mock data + * @param data Optional array of page data to convert to Page instances + * @returns Array of Page instances + */ +export const mockPage = (data: TPage[] = mockPageData()): TPage[] => data.map(item => new Page(item)) diff --git a/src/entities/page/page.spec.ts b/src/entities/page/page.spec.ts new file mode 100644 index 00000000..fa342192 --- /dev/null +++ b/src/entities/page/page.spec.ts @@ -0,0 +1,47 @@ +/* eslint-disable no-console */ +import { Page } from './page' +import { mockPage } from './page.mock' + +describe('Page Store', () => { + it('create Page entity with full data', () => { + const page = new Page(mockPage()[0]) + + expect(page).toBeInstanceOf(Page) + expect(page).toEqual(mockPage()[0]) + expect(page.uuid).toBe(mockPage()[0].uuid) + expect(page.name).toBe(mockPage()[0].name) + expect(page.slug).toBe(mockPage()[0].slug) + expect(page.contents).toEqual(mockPage()[0].contents) + expect(page.createdAt).toBe(mockPage()[0].createdAt) + expect(page.updatedAt).toBe(mockPage()[0].updatedAt) + + expect(page.validate().success).toBe(true) + }) + + it('create Page entity with partial data', () => { + const page = new Page(mockPage()[1]) + + expect(page).toBeInstanceOf(Page) + expect(page.id).toBe(mockPage()[1].id) + expect(page.uuid).toBe(mockPage()[1].uuid) + expect(page.name).toBe(mockPage()[1].name) + expect(page.slug).toBe(mockPage()[1].slug) + expect(page.contents).toBeNull() + expect(page.createdAt).toBe(mockPage()[1].createdAt) + expect(page.updatedAt).toBe(mockPage()[1].updatedAt) + + expect(page.validate().success).toBe(true) + }) + + it('create Page entity with falsy data', () => { + const page = new Page(mockPage()[2]) + + expect(page).toBeInstanceOf(Page) + expect(page).toEqual(mockPage()[2]) + expect(page.uuid).toBe(mockPage()[2].uuid) + expect(page.name).toBe('') + expect(page.contents).toBeNull() + + expect(page.validate().success).toBe(false) + }) +}) diff --git a/src/entities/page/page.ts b/src/entities/page/page.ts new file mode 100644 index 00000000..978eaf07 --- /dev/null +++ b/src/entities/page/page.ts @@ -0,0 +1,74 @@ +import { SafeParseReturnType, z } from 'zod' +import { TPage } from './page.types' + +/** + * Page class representing a page entity with validation + * Implements the TPage interface for type safety + */ +export class Page implements TPage { + + public id: string + public uuid: string + public name: string + public slug: string + public contents: Array + public createdAt: string + public updatedAt: string + + /** + * Creates a new Page instance + * @param data Initial page data conforming to TPage interface + */ + constructor(data: TPage) { + this.hydrate(data) + } + + /* istanbul ignore next */ // Jest does not recognize the code coverage of these 2 methods + /** + * Hydrates the page object with provided data + * @param data Page data to populate the instance + */ + private hydrate(data: TPage) { + this.id = data?.id?.toString() || '' + this.uuid = data?.uuid || '' + this.name = data?.name || '' + this.slug = data?.slug || '' + this.contents = data?.contents?.map(contents => ({ + title: contents?.title || '', + summary: contents?.summary || '', + description: contents?.description || '', + image: contents?.image || '' + })) || [] + this.createdAt = data?.createdAt || '' + this.updatedAt = data?.updatedAt || '' + } + + /* istanbul ignore next */ + /** + * Validates the page data against a schema + * @returns SafeParseReturnType containing validation result + */ + public validate(): SafeParseReturnType { + // Schema validation for page data + const schema = z.object({ + uuid: z.string().min(1, 'is verplicht'), + name: z.string().min(1, 'is verplicht'), + slug: z.string().min(1, 'is verplicht'), + contents: z.array(z.object({ + title: z.string().min(1, 'is verplicht'), + summary: z.string().min(1, 'is verplicht'), + description: z.string(), + image: z.string() + })), + createdAt: z.string(), + updatedAt: z.string() + }) + + const result = schema.safeParse({ + ...this, + }) + + return result + } + +} diff --git a/src/entities/page/page.types.ts b/src/entities/page/page.types.ts new file mode 100644 index 00000000..2aea7d70 --- /dev/null +++ b/src/entities/page/page.types.ts @@ -0,0 +1,13 @@ +/** + * Type definition for a Page object + * Represents the structure of a page with content and metadata + */ +export type TPage = { + id: string // Unique identifier for the page + uuid: string // Unique identifier for the page + name: string // Title/heading of the page + contents: Array // Main content/body of the page - can contain any type of content + slug: string // URL-friendly version of the title + createdAt: string // Creation timestamp + updatedAt: string // Last update timestamp +} diff --git a/src/navigation/MainMenu.vue b/src/navigation/MainMenu.vue index 0b590dcf..896e5742 100644 --- a/src/navigation/MainMenu.vue +++ b/src/navigation/MainMenu.vue @@ -53,6 +53,11 @@ import { navigationStore, catalogiStore, publicationStore } from '../store/store + + + @@ -92,6 +97,7 @@ import Finance from 'vue-material-design-icons/Finance.vue' import BookOpenVariantOutline from 'vue-material-design-icons/BookOpenVariantOutline.vue' import OfficeBuildingOutline from 'vue-material-design-icons/OfficeBuildingOutline.vue' import ShapeOutline from 'vue-material-design-icons/ShapeOutline.vue' +import Web from 'vue-material-design-icons/Web.vue' export default { name: 'MainMenu', @@ -114,6 +120,7 @@ export default { BookOpenVariantOutline, OfficeBuildingOutline, ShapeOutline, + Web, }, data() { return { diff --git a/src/store/modules/page.spec.ts b/src/store/modules/page.spec.ts new file mode 100644 index 00000000..be1dbb7a --- /dev/null +++ b/src/store/modules/page.spec.ts @@ -0,0 +1,53 @@ +/* eslint-disable no-console */ +import { setActivePinia, createPinia } from 'pinia' + +import { usePageStore } from './page.js' +import { mockPage, Page } from '../../entities/index.js' + +describe('Page Store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('sets page item correctly', () => { + const store = usePageStore() + + store.setPageItem(mockPage()[0]) + + expect(store.pageItem).toBeInstanceOf(Page) + expect(store.pageItem).toEqual(mockPage()[0]) + expect(store.pageItem.validate().success).toBe(true) + + store.setPageItem(mockPage()[1]) + + expect(store.pageItem).toBeInstanceOf(Page) + expect(store.pageItem).toEqual(mockPage()[1]) + expect(store.pageItem.validate().success).toBe(true) + + store.setPageItem(mockPage()[2]) + + expect(store.pageItem).toBeInstanceOf(Page) + expect(store.pageItem).toEqual(mockPage()[2]) + expect(store.pageItem.validate().success).toBe(false) + }) + + it('sets page list correctly', () => { + const store = usePageStore() + + store.setPageList(mockPage()) + + expect(store.pageList).toHaveLength(mockPage().length) + + expect(store.pageList[0]).toBeInstanceOf(Page) + expect(store.pageList[0]).toEqual(mockPage()[0]) + expect(store.pageList[0].validate().success).toBe(true) + + expect(store.pageList[1]).toBeInstanceOf(Page) + expect(store.pageList[1]).toEqual(mockPage()[1]) + expect(store.pageList[1].validate().success).toBe(true) + + expect(store.pageList[2]).toBeInstanceOf(Page) + expect(store.pageList[2]).toEqual(mockPage()[2]) + expect(store.pageList[2].validate().success).toBe(false) + }) +}) diff --git a/src/store/modules/page.ts b/src/store/modules/page.ts new file mode 100644 index 00000000..0d132a7c --- /dev/null +++ b/src/store/modules/page.ts @@ -0,0 +1,160 @@ +/* eslint-disable no-console */ +import { Page, TPage } from '../../entities/index.js' +import { defineStore } from 'pinia' + +const apiEndpoint = '/index.php/apps/opencatalogi/api/pages' + +interface Options { + /** + * Do not save the received item to the store, this can be enabled if API calls get run in a loop + */ + doNotSetStore?: boolean +} + +interface PageStoreState { + pageItem: Page; + pageList: Page[]; +} + +export const usePageStore = defineStore('page', { + state: () => ({ + pageItem: null, + pageList: [], + } as PageStoreState), + actions: { + setPageItem(pageItem: Page | TPage) { + this.pageItem = pageItem && new Page(pageItem as TPage) + console.log('Active page item set to ' + pageItem && pageItem?.id) + }, + setPageList(pageList: Page[] | TPage[]) { + this.pageList = pageList.map( + (pageItem) => new Page(pageItem as TPage), + ) + console.log('Page list set to ' + pageList.length + ' items') + }, + /* istanbul ignore next */ // ignore this for Jest until moved into a service + async refreshPageList(search: string = null) { + // @todo this might belong in a service? + let endpoint = apiEndpoint + if (search !== null && search !== '') { + endpoint = endpoint + '?_search=' + search + } + const response = await fetch( + endpoint, { + method: 'GET', + }, + ) + const rawData = (await response.json()).results + const data = rawData.map((pageItem: TPage) => new Page(pageItem)) + + this.setPageList(data) + + return { response, data } + }, + /* istanbul ignore next */ + async getAllPages(options: Options = {}) { + const response = await fetch( + `${apiEndpoint}`, + { method: 'get' }, + ) + + const rawData = await response.json() + + const data = rawData.results.map((pageItem: TPage) => new Page(pageItem)) + + options.doNotSetStore !== true && this.setPageList(data) + + return { response, data } + }, + /* istanbul ignore next */ + async getOnePage(id: number, options: Options = {}) { + if (!id) { + throw Error('Passed id is falsy') + } + + const response = await fetch( + `${apiEndpoint}/${id}`, + { method: 'get' }, + ) + + const data = new Page(await response.json()) + + options.doNotSetStore !== true && this.setPageItem(data) + + return { response, data } + }, + /* istanbul ignore next */ + async addPage(item: Page) { + if (!(item instanceof Page)) { + throw Error('Please pass a Page item from the Page class') + } + + const validateResult = item.validate() + if (!validateResult.success) { + throw Error(validateResult.error.issues[0].message) + } + + const response = await fetch(apiEndpoint, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(validateResult.data), + }, + ) + + const data = new Page(await response.json()) + + this.refreshPageList() + this.setPageItem(data) + + return { response, data } + }, + /* istanbul ignore next */ + async editPage(pageItem: Page) { + if (!(pageItem instanceof Page)) { + throw Error('Please pass a Page item from the Page class') + } + + const validateResult = pageItem.validate() + if (!validateResult.success) { + throw Error(validateResult.error.issues[0].message) + } + + const response = await fetch( + `${apiEndpoint}/${pageItem.id}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(validateResult.data), + }, + ) + + const data = new Page(await response.json()) + + this.refreshPageList() + this.setPageItem(data) + + return { response, data } + }, + /* istanbul ignore next */ + async deletePage(id: number) { + if (!id) { + throw Error('Passed id is falsy') + } + + const response = await fetch( + `${apiEndpoint}/${id}`, + { method: 'DELETE' }, + ) + + this.refreshPageList() + this.setPageItem(null) + + return { response } + }, + }, +}) diff --git a/src/store/store.js b/src/store/store.js index 1eddc424..0f1b9e7d 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -14,6 +14,7 @@ import { useOrganizationStore } from './modules/organization' import { usePublicationStore } from './modules/publication' import { useSearchStore } from './modules/search' import { useThemeStore } from './modules/theme' +import { usePageStore } from './modules/page' const navigationStore = useNavigationStore(pinia) const searchStore = useSearchStore(pinia) @@ -24,6 +25,7 @@ const publicationStore = usePublicationStore(pinia) const organizationStore = useOrganizationStore(pinia) const themeStore = useThemeStore(pinia) const configurationStore = useConfigurationStore(pinia) +const pageStore = usePageStore(pinia) export { // generic @@ -37,4 +39,5 @@ export { organizationStore, themeStore, configurationStore, + pageStore, } diff --git a/src/views/Views.vue b/src/views/Views.vue index 8ba8cae6..ca810743 100644 --- a/src/views/Views.vue +++ b/src/views/Views.vue @@ -9,6 +9,7 @@ import { navigationStore } from '../store/store.js' + @@ -23,6 +24,7 @@ import { NcAppContent } from '@nextcloud/vue' import Catalogi from './catalogi/CatalogiIndex.vue' import Organizations from './organizations/OrganizationIndex.vue' import Themes from './themes/ThemeIndex.vue' +import Pages from './pages/PageIndex.vue' import Dashboard from './dashboard/DashboardIndex.vue' import Directory from './directory/DirectoryIndex.vue' import PublicationType from './publicationType/PublicationTypeIndex.vue' @@ -35,6 +37,7 @@ export default { Catalogi, Organizations, Themes, + Pages, Dashboard, Directory, PublicationType, diff --git a/src/views/pages/PageDetail.vue b/src/views/pages/PageDetail.vue new file mode 100644 index 00000000..e01d6930 --- /dev/null +++ b/src/views/pages/PageDetail.vue @@ -0,0 +1,202 @@ + + + + + + + diff --git a/src/views/pages/PageIndex.vue b/src/views/pages/PageIndex.vue new file mode 100644 index 00000000..8d1f20cf --- /dev/null +++ b/src/views/pages/PageIndex.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/src/views/pages/PageList.vue b/src/views/pages/PageList.vue new file mode 100644 index 00000000..49115cfe --- /dev/null +++ b/src/views/pages/PageList.vue @@ -0,0 +1,202 @@ + + + + + From 9fa9f7125ec4314bc51accdce53f04fc2ca7e05a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 29 Nov 2024 15:26:23 +0100 Subject: [PATCH 15/21] First fixes --- appinfo/info.xml | 2 +- lib/Controller/{PagesController => PagesController.php} | 0 src/navigation/MainMenu.vue | 4 ++-- src/views/pages/PageDetail.vue | 2 ++ src/views/pages/PageIndex.vue | 6 +++--- src/views/pages/PageList.vue | 6 +++--- 6 files changed, 11 insertions(+), 9 deletions(-) rename lib/Controller/{PagesController => PagesController.php} (100%) diff --git a/appinfo/info.xml b/appinfo/info.xml index eb985017..e9496ccd 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Create a [bug report](https://github.com/OpenCatalogi/.github/issues/new/choose) Create a [feature request](https://github.com/OpenCatalogi/.github/issues/new/choose) ]]> - 0.6.42 + 0.6.43 agpl Conduction Acato diff --git a/lib/Controller/PagesController b/lib/Controller/PagesController.php similarity index 100% rename from lib/Controller/PagesController rename to lib/Controller/PagesController.php diff --git a/src/navigation/MainMenu.vue b/src/navigation/MainMenu.vue index 896e5742..cb984d36 100644 --- a/src/navigation/MainMenu.vue +++ b/src/navigation/MainMenu.vue @@ -54,12 +54,12 @@ import { navigationStore, catalogiStore, publicationStore } from '../store/store diff --git a/src/views/pages/PageDetail.vue b/src/views/pages/PageDetail.vue index e01d6930..031a44b7 100644 --- a/src/views/pages/PageDetail.vue +++ b/src/views/pages/PageDetail.vue @@ -80,6 +80,7 @@ import Delete from 'vue-material-design-icons/Delete.vue' import DotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue' import HelpCircleOutline from 'vue-material-design-icons/HelpCircleOutline.vue' import Pencil from 'vue-material-design-icons/Pencil.vue' +import Web from 'vue-material-design-icons/Web.vue' /** * Component for displaying and managing page details @@ -97,6 +98,7 @@ export default { Delete, ContentCopy, HelpCircleOutline, + Web, }, props: { pageItem: { diff --git a/src/views/pages/PageIndex.vue b/src/views/pages/PageIndex.vue index 8d1f20cf..5e4cbbb3 100644 --- a/src/views/pages/PageIndex.vue +++ b/src/views/pages/PageIndex.vue @@ -13,7 +13,7 @@ import { navigationStore, searchStore, pageStore } from '../../store/store.js' name="Geen pagina" description="Nog geen pagina geselecteerd">