From 515d08ea796b101ea4aaadde015d0cc8be9cb893 Mon Sep 17 00:00:00 2001 From: Hoang Pham Date: Tue, 5 May 2026 00:12:19 +0700 Subject: [PATCH] Implement whiteboard library templates Signed-off-by: Hoang Pham --- appinfo/routes.php | 25 + lib/AppInfo/Application.php | 11 + lib/Controller/PickerController.php | 102 ++++ lib/Controller/SettingsController.php | 95 +++ lib/Controller/WhiteboardController.php | 103 +++- .../BeforeTemplateRenderedListener.php | 7 + .../FilesLoadAdditionalScriptsListener.php | 36 ++ lib/Listener/LoadViewerListener.php | 7 + .../RegisterTemplateCreatorListener.php | 1 - lib/Service/CanvasTemplateService.php | 217 +++++++ lib/Service/WhiteboardContentService.php | 90 ++- lib/Service/WhiteboardFolderService.php | 224 +++++++ lib/Service/WhiteboardLibraryService.php | 558 +++++++++++++++--- lib/Settings/Admin.php | 4 + lib/Template/GlobalTemplateProvider.php | 105 ++++ package-lock.json | 299 +++++++++- package.json | 2 +- src/App.tsx | 239 +++++++- src/components/AdminSettings.vue | 298 +++++++++- src/components/PersonalSettings.vue | 1 + src/components/SaveScopedDialog.tsx | 97 +++ src/files.ts | 233 ++++++++ src/hooks/useBoardDataManager.ts | 7 +- src/hooks/useCanvasTemplate.ts | 42 ++ src/hooks/useLibrary.ts | 12 +- src/hooks/useLibraryCatalog.ts | 103 ++++ src/stores/useWhiteboardConfigStore.ts | 8 + src/styles/globals/_layout.scss | 114 ++++ tests/Unit/AppInfo/ApplicationTest.php | 5 +- .../Unit/Controller/PickerControllerTest.php | 135 +++++ .../Controller/WhiteboardControllerTest.php | 179 ++++++ .../Service/CanvasTemplateServiceTest.php | 260 ++++++++ .../Service/WhiteboardContentServiceTest.php | 154 +++++ .../Service/WhiteboardFolderServiceTest.php | 78 +++ .../Service/WhiteboardLibraryServiceTest.php | 387 ++++++++++++ vite.config.ts | 1 + 36 files changed, 4124 insertions(+), 115 deletions(-) create mode 100644 lib/Controller/PickerController.php create mode 100644 lib/Listener/FilesLoadAdditionalScriptsListener.php create mode 100644 lib/Service/CanvasTemplateService.php create mode 100644 lib/Service/WhiteboardFolderService.php create mode 100644 lib/Template/GlobalTemplateProvider.php create mode 100644 src/components/SaveScopedDialog.tsx create mode 100644 src/files.ts create mode 100644 src/hooks/useCanvasTemplate.ts create mode 100644 src/hooks/useLibraryCatalog.ts create mode 100644 tests/Unit/Controller/PickerControllerTest.php create mode 100644 tests/Unit/Controller/WhiteboardControllerTest.php create mode 100644 tests/Unit/Service/CanvasTemplateServiceTest.php create mode 100644 tests/Unit/Service/WhiteboardContentServiceTest.php create mode 100644 tests/Unit/Service/WhiteboardFolderServiceTest.php create mode 100644 tests/Unit/Service/WhiteboardLibraryServiceTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 3e8e0cb3..0dfaa0db 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -9,6 +9,7 @@ use OCA\Whiteboard\Controller\AiController; use OCA\Whiteboard\Controller\JWTController; +use OCA\Whiteboard\Controller\PickerController; use OCA\Whiteboard\Controller\RecordingController; use OCA\Whiteboard\Controller\SettingsController; use OCA\Whiteboard\Controller\WhiteboardController; @@ -23,6 +24,18 @@ ['name' => 'Whiteboard#getLib', 'url' => 'library', 'verb' => 'GET'], /** @see WhiteboardController::updateLib() */ ['name' => 'Whiteboard#updateLib', 'url' => 'library', 'verb' => 'PUT'], + /** @see WhiteboardController::listLibraries() */ + ['name' => 'Whiteboard#listLibraries', 'url' => 'libraries', 'verb' => 'GET'], + /** @see WhiteboardController::resolveLibrary() */ + ['name' => 'Whiteboard#resolveLibrary', 'url' => 'libraries/resolve', 'verb' => 'GET'], + /** @see WhiteboardController::saveLibrary() */ + ['name' => 'Whiteboard#saveLibrary', 'url' => 'libraries', 'verb' => 'POST'], + /** @see WhiteboardController::deleteLibrary() */ + ['name' => 'Whiteboard#deleteLibrary', 'url' => 'libraries/{scope}/{name}', 'verb' => 'DELETE'], + /** @see WhiteboardController::publishCanvasTemplate() */ + ['name' => 'Whiteboard#publishCanvasTemplate', 'url' => 'canvas-template', 'verb' => 'POST'], + /** @see PickerController::index() */ + ['name' => 'Picker#index', 'url' => 'picker', 'verb' => 'GET'], /** @see WhiteboardController::update() */ ['name' => 'Whiteboard#update', 'url' => '{fileId}', 'verb' => 'PUT'], /** @see WhiteboardController::show() */ @@ -35,5 +48,17 @@ ['name' => 'Settings#update', 'url' => 'settings', 'verb' => 'POST'], /** @see SettingsController::updatePersonal() */ ['name' => 'Settings#updatePersonal', 'url' => 'settings/personal', 'verb' => 'POST'], + /** @see SettingsController::listOrgLibraries() */ + ['name' => 'Settings#listOrgLibraries', 'url' => 'settings/org-library', 'verb' => 'GET'], + /** @see SettingsController::uploadOrgLibrary() */ + ['name' => 'Settings#uploadOrgLibrary', 'url' => 'settings/org-library', 'verb' => 'POST'], + /** @see SettingsController::deleteOrgLibrary() */ + ['name' => 'Settings#deleteOrgLibrary', 'url' => 'settings/org-library/{name}', 'verb' => 'DELETE'], + /** @see SettingsController::listOrgCanvasTemplates() */ + ['name' => 'Settings#listOrgCanvasTemplates', 'url' => 'settings/org-canvas-template', 'verb' => 'GET'], + /** @see SettingsController::uploadOrgCanvasTemplate() */ + ['name' => 'Settings#uploadOrgCanvasTemplate', 'url' => 'settings/org-canvas-template', 'verb' => 'POST'], + /** @see SettingsController::deleteOrgCanvasTemplate() */ + ['name' => 'Settings#deleteOrgCanvasTemplate', 'url' => 'settings/org-canvas-template/{name}', 'verb' => 'DELETE'], ] ]; diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index c4627779..9d9199d5 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -9,15 +9,18 @@ namespace OCA\Whiteboard\AppInfo; +use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; use OCA\Viewer\Event\LoadViewer; use OCA\Whiteboard\Listener\AddContentSecurityPolicyListener; use OCA\Whiteboard\Listener\BeforeTemplateRenderedListener; +use OCA\Whiteboard\Listener\FilesLoadAdditionalScriptsListener; use OCA\Whiteboard\Listener\LoadTextEditorListener; use OCA\Whiteboard\Listener\LoadViewerListener; use OCA\Whiteboard\Listener\RegisterDirectEditorListener; use OCA\Whiteboard\Listener\RegisterTemplateCreatorListener; use OCA\Whiteboard\Settings\SetupCheck; +use OCA\Whiteboard\Template\GlobalTemplateProvider; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -50,6 +53,14 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateCreatorListener::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); $context->registerEventListener(RegisterDirectEditorEvent::class, RegisterDirectEditorListener::class); + + [$major] = Util::getVersion(); + if ($major >= 30) { + $context->registerTemplateProvider(GlobalTemplateProvider::class); + // The picker enhancer only targets the template picker markup that + // ships alongside the template provider API gated above. + $context->registerEventListener(LoadAdditionalScriptsEvent::class, FilesLoadAdditionalScriptsListener::class); + } $context->registerSetupCheck(SetupCheck::class); } diff --git a/lib/Controller/PickerController.php b/lib/Controller/PickerController.php new file mode 100644 index 00000000..3955f206 --- /dev/null +++ b/lib/Controller/PickerController.php @@ -0,0 +1,102 @@ +libraryService->getOrgLibraryPointerFiles() as $file) { + $entries[(string)$file->getId()] = ['kind' => self::KIND_LIBRARY, 'scope' => WhiteboardFolderService::SCOPE_ORG]; + } + } catch (\Throwable $e) { + $this->logger->warning('Failed to list organization library pointers for picker', ['app' => 'whiteboard', 'exception' => $e]); + } + + try { + foreach ($this->canvasTemplateService->getOrgCanvasTemplateFiles() as $file) { + $entries[(string)$file->getId()] = ['kind' => self::KIND_CANVAS_TEMPLATE, 'scope' => WhiteboardFolderService::SCOPE_ORG]; + } + } catch (\Throwable $e) { + $this->logger->warning('Failed to list organization canvas templates for picker', ['app' => 'whiteboard', 'exception' => $e]); + } + + try { + $uid = $this->userSession->getUser()?->getUID(); + if ($uid !== null && $uid !== '') { + foreach ($this->folders->getUserTemplateFolder($uid)->getDirectoryListing() as $node) { + if (!$node instanceof File || !str_ends_with(strtolower($node->getName()), '.whiteboard')) { + continue; + } + $entries[(string)$node->getId()] = [ + 'kind' => $this->isLibraryPointer($node) ? self::KIND_LIBRARY : self::KIND_CANVAS_TEMPLATE, + 'scope' => WhiteboardFolderService::SCOPE_PERSONAL, + ]; + } + } + } catch (\Throwable $e) { + $this->logger->warning('Failed to list personal templates for picker', ['app' => 'whiteboard', 'exception' => $e]); + } + + return new DataResponse(['entries' => $entries]); + } + + private function isLibraryPointer(File $file): bool { + if ($file->getSize() > self::MAX_POINTER_BYTES) { + return false; + } + try { + $content = json_decode($file->getContent(), true, 16); + } catch (\Throwable) { + return false; + } + return is_array($content) && isset($content['libraryRef']); + } +} diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 3b46cc88..aacbeee6 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -10,11 +10,14 @@ namespace OCA\Whiteboard\Controller; use Exception; +use OCA\Whiteboard\Service\CanvasTemplateService; use OCA\Whiteboard\Service\ConfigService; use OCA\Whiteboard\Service\ExceptionService; use OCA\Whiteboard\Service\JWTService; +use OCA\Whiteboard\Service\WhiteboardLibraryService; use OCA\Whiteboard\Settings\SetupCheck; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCP\IRequest; use OCP\IUserSession; @@ -31,6 +34,8 @@ public function __construct( private ConfigService $configService, private SetupCheck $setupCheck, private IUserSession $userSession, + private WhiteboardLibraryService $libraryService, + private CanvasTemplateService $canvasTemplateService, ) { parent::__construct('whiteboard', $request); } @@ -91,4 +96,94 @@ public function updatePersonal(): DataResponse { return $this->exceptionService->handleException($e); } } + + public function listOrgLibraries(): DataResponse { + try { + return new DataResponse(['libraries' => $this->libraryService->listLibraries($this->uid())['org']]); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); + } + } + + public function uploadOrgLibrary(): DataResponse { + try { + $uploadedFile = $this->request->getUploadedFile('file'); + if (!is_array($uploadedFile) || !isset($uploadedFile['tmp_name'], $uploadedFile['name'])) { + throw new Exception('No library uploaded', Http::STATUS_BAD_REQUEST); + } + if (($uploadedFile['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { + throw new Exception('Library upload failed', Http::STATUS_BAD_REQUEST); + } + + $content = file_get_contents($uploadedFile['tmp_name']); + if ($content === false) { + throw new Exception('Failed to read uploaded library', Http::STATUS_BAD_REQUEST); + } + + $name = (string)preg_replace('/\.excalidrawlib$/i', '', (string)$uploadedFile['name']); + $items = $this->libraryService->parseLibraryFile($content); + + return new DataResponse([ + 'library' => $this->libraryService->saveLibrary($this->uid(), 'org', $name, $items), + ], Http::STATUS_CREATED); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); + } + } + + public function deleteOrgLibrary(string $name): DataResponse { + try { + $this->libraryService->deleteLibrary($this->uid(), 'org', $name); + return new DataResponse(['status' => 'success']); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); + } + } + + public function listOrgCanvasTemplates(): DataResponse { + try { + return new DataResponse(['canvasTemplates' => $this->canvasTemplateService->listOrgCanvasTemplates()]); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); + } + } + + public function uploadOrgCanvasTemplate(): DataResponse { + try { + $uploadedFile = $this->request->getUploadedFile('file'); + if (!is_array($uploadedFile) || !isset($uploadedFile['tmp_name'], $uploadedFile['name'])) { + throw new Exception('No canvas uploaded', Http::STATUS_BAD_REQUEST); + } + if (($uploadedFile['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { + throw new Exception('Canvas upload failed', Http::STATUS_BAD_REQUEST); + } + + $content = file_get_contents($uploadedFile['tmp_name']); + if ($content === false) { + throw new Exception('Failed to read uploaded canvas', Http::STATUS_BAD_REQUEST); + } + + $name = (string)preg_replace('/\.whiteboard$/i', '', (string)$uploadedFile['name']); + $data = $this->canvasTemplateService->parseCanvasTemplateData($content); + + return new DataResponse([ + 'canvasTemplate' => $this->canvasTemplateService->publishCanvasTemplate($this->uid(), 'org', $name, $data), + ], Http::STATUS_CREATED); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); + } + } + + public function deleteOrgCanvasTemplate(string $name): DataResponse { + try { + $this->canvasTemplateService->deleteOrgCanvasTemplate($name); + return new DataResponse(['status' => 'success']); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); + } + } + + private function uid(): string { + return $this->userSession->getUser()?->getUID() ?? ''; + } } diff --git a/lib/Controller/WhiteboardController.php b/lib/Controller/WhiteboardController.php index c16f4473..dfa4c5c1 100644 --- a/lib/Controller/WhiteboardController.php +++ b/lib/Controller/WhiteboardController.php @@ -10,9 +10,11 @@ namespace OCA\Whiteboard\Controller; use Exception; +use InvalidArgumentException; use OCA\Whiteboard\Exception\InvalidUserException; use OCA\Whiteboard\Exception\UnauthorizedException; use OCA\Whiteboard\Service\Authentication\GetUserFromIdServiceFactory; +use OCA\Whiteboard\Service\CanvasTemplateService; use OCA\Whiteboard\Service\ConfigService; use OCA\Whiteboard\Service\ExceptionService; use OCA\Whiteboard\Service\File\GetFileServiceFactory; @@ -20,11 +22,13 @@ use OCA\Whiteboard\Service\WhiteboardContentService; use OCA\Whiteboard\Service\WhiteboardLibraryService; use OCP\AppFramework\ApiController; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\ICacheFactory; +use OCP\IGroupManager; use OCP\IMemcache; use OCP\IRequest; use Psr\Log\LoggerInterface; @@ -44,10 +48,12 @@ public function __construct( private JWTService $jwtService, private WhiteboardContentService $contentService, private WhiteboardLibraryService $libraryService, + private CanvasTemplateService $canvasTemplateService, private ExceptionService $exceptionService, private ConfigService $configService, private LoggerInterface $logger, private ICacheFactory $cacheFactory, + private IGroupManager $groupManager, ) { parent::__construct($appName, $request); $this->cache = $cacheFactory->createLocking('whiteboard_sync'); @@ -108,8 +114,8 @@ public function update(int $fileId, array $data): DataResponse { public function getLib(): DataResponse { try { $jwt = $this->getJwtFromRequest(); - $this->jwtService->getUserIdFromJWT($jwt); - $data = $this->libraryService->getUserLib(); + $userId = $this->jwtService->getUserIdFromJWT($jwt); + $data = $this->libraryService->getUserLib($userId); return new DataResponse(['data' => $data]); } catch (Exception $e) { @@ -133,6 +139,99 @@ public function updateLib(): DataResponse { } } + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + public function listLibraries(): DataResponse { + try { + $jwt = $this->getJwtFromRequest(); + $userId = $this->jwtService->getUserIdFromJWT($jwt); + return new DataResponse(['data' => $this->libraryService->listLibraries($userId)]); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); + } + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + public function resolveLibrary(): DataResponse { + try { + $jwt = $this->getJwtFromRequest(); + $userId = $this->jwtService->getUserIdFromJWT($jwt); + $scope = (string)$this->request->getParam('scope', ''); + $name = (string)$this->request->getParam('name', ''); + return new DataResponse(['data' => $this->libraryService->resolveLibrary($userId, $scope, $name)]); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); + } + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + public function saveLibrary(): DataResponse { + try { + $jwt = $this->getJwtFromRequest(); + $userId = $this->jwtService->getUserIdFromJWT($jwt); + $scope = (string)$this->request->getParam('scope', 'personal'); + $name = (string)$this->request->getParam('name', ''); + $items = $this->request->getParam('items', []); + if (!is_array($items)) { + throw new InvalidArgumentException('Invalid library items', Http::STATUS_BAD_REQUEST); + } + if ($scope === 'org' && !$this->groupManager->isAdmin($userId)) { + throw new InvalidArgumentException('Only administrators can save organization libraries', Http::STATUS_FORBIDDEN); + } + return new DataResponse(['library' => $this->libraryService->saveLibrary($userId, $scope, $name, $items)]); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); + } + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + public function deleteLibrary(string $scope, string $name): DataResponse { + try { + $jwt = $this->getJwtFromRequest(); + $userId = $this->jwtService->getUserIdFromJWT($jwt); + if ($scope === 'org' && !$this->groupManager->isAdmin($userId)) { + throw new InvalidArgumentException('Only administrators can delete organization libraries', Http::STATUS_FORBIDDEN); + } + $this->libraryService->deleteLibrary($userId, $scope, $name); + return new DataResponse(['status' => 'success']); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); + } + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + public function publishCanvasTemplate(): DataResponse { + try { + $jwt = $this->getJwtFromRequest(); + $userId = $this->jwtService->getUserIdFromJWT($jwt); + $scope = (string)$this->request->getParam('scope', 'personal'); + $name = (string)$this->request->getParam('name', ''); + $data = $this->request->getParam('data'); + if (!is_array($data)) { + throw new InvalidArgumentException('Invalid canvas template data', Http::STATUS_BAD_REQUEST); + } + if ($scope === 'org' && !$this->groupManager->isAdmin($userId)) { + throw new InvalidArgumentException('Only administrators can publish organization canvas templates', Http::STATUS_FORBIDDEN); + } + $parsed = $this->canvasTemplateService->parseCanvasTemplateData($data); + return new DataResponse( + ['canvasTemplate' => $this->canvasTemplateService->publishCanvasTemplate($userId, $scope, $name, $parsed)], + Http::STATUS_CREATED + ); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); + } + } + private function getJwtFromRequest(): string { $authHeader = $this->request->getHeader('Authorization'); if (sscanf($authHeader, 'Bearer %s', $jwt) !== 1) { diff --git a/lib/Listener/BeforeTemplateRenderedListener.php b/lib/Listener/BeforeTemplateRenderedListener.php index 71c7b0de..4e07ba8f 100644 --- a/lib/Listener/BeforeTemplateRenderedListener.php +++ b/lib/Listener/BeforeTemplateRenderedListener.php @@ -17,6 +17,7 @@ use OCP\EventDispatcher\IEventListener; use OCP\Files\File; use OCP\Files\NotFoundException; +use OCP\IGroupManager; use OCP\IUserSession; use OCP\Util; @@ -31,6 +32,7 @@ public function __construct( private ConfigService $configService, private IEventDispatcher $eventDispatcher, private IUserSession $userSession, + private IGroupManager $groupManager, ) { } @@ -74,5 +76,10 @@ public function handle(Event $event): void { 'autoUploadOnDisconnect', $user ? $this->configService->getUserAutoUploadOnDisconnect($user->getUID()) : false, ); + $this->initialState->provideInitialState( + 'isAdmin', + $user !== null && $this->groupManager->isAdmin($user->getUID()), + ); + $this->initialState->provideInitialState('orgTemplatesSupported', Util::getVersion()[0] >= 30); } } diff --git a/lib/Listener/FilesLoadAdditionalScriptsListener.php b/lib/Listener/FilesLoadAdditionalScriptsListener.php new file mode 100644 index 00000000..7d7ff31c --- /dev/null +++ b/lib/Listener/FilesLoadAdditionalScriptsListener.php @@ -0,0 +1,36 @@ + + * @psalm-suppress UndefinedClass + * @psalm-suppress UndefinedDocblockClass + */ +final class FilesLoadAdditionalScriptsListener implements IEventListener { + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof LoadAdditionalScriptsEvent)) { + return; + } + + Util::addInitScript(Application::APP_ID, 'whiteboard-files'); + } +} diff --git a/lib/Listener/LoadViewerListener.php b/lib/Listener/LoadViewerListener.php index 63ad0892..7e587b4b 100644 --- a/lib/Listener/LoadViewerListener.php +++ b/lib/Listener/LoadViewerListener.php @@ -14,6 +14,7 @@ use OCP\AppFramework\Services\IInitialState; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; +use OCP\IGroupManager; use OCP\IUserSession; use OCP\Util; @@ -23,6 +24,7 @@ public function __construct( private IInitialState $initialState, private ConfigService $configService, private IUserSession $userSession, + private IGroupManager $groupManager, ) { } @@ -52,5 +54,10 @@ public function handle(Event $event): void { 'autoUploadOnDisconnect', $user ? $this->configService->getUserAutoUploadOnDisconnect($user->getUID()) : false ); + $this->initialState->provideInitialState( + 'isAdmin', + $user !== null && $this->groupManager->isAdmin($user->getUID()) + ); + $this->initialState->provideInitialState('orgTemplatesSupported', Util::getVersion()[0] >= 30); } } diff --git a/lib/Listener/RegisterTemplateCreatorListener.php b/lib/Listener/RegisterTemplateCreatorListener.php index a319ddf9..6511b045 100644 --- a/lib/Listener/RegisterTemplateCreatorListener.php +++ b/lib/Listener/RegisterTemplateCreatorListener.php @@ -40,7 +40,6 @@ public function handle(Event $event): void { public static function getTemplateFileCreator(IL10N $l10n): TemplateFileCreator { $whiteboard = new TemplateFileCreator(Application::APP_ID, $l10n->t('New whiteboard'), '.whiteboard'); $whiteboard->addMimetype('application/vnd.excalidraw+json'); - $whiteboard->addMimetype('application/octet-stream'); // Always use the custom SVG icon for consistency $iconContent = file_get_contents(__DIR__ . '/../../img/app-filetype.svg'); diff --git a/lib/Service/CanvasTemplateService.php b/lib/Service/CanvasTemplateService.php new file mode 100644 index 00000000..10cc8263 --- /dev/null +++ b/lib/Service/CanvasTemplateService.php @@ -0,0 +1,217 @@ + $raw + * + * @return array{elements: array, files: array, appState?: array, scrollToContent: true} + */ + public function parseCanvasTemplateData(string|array $raw): array { + if (is_string($raw)) { + if (strlen($raw) > self::MAX_CANVAS_TEMPLATE_BYTES) { + throw new InvalidArgumentException('Canvas is too large (max 15 MB).', Http::STATUS_BAD_REQUEST); + } + try { + $raw = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw new InvalidArgumentException('This is not a valid whiteboard file.', Http::STATUS_BAD_REQUEST); + } + } + if (!is_array($raw)) { + throw new InvalidArgumentException('This is not a valid whiteboard file.', Http::STATUS_BAD_REQUEST); + } + + $elements = []; + foreach (is_array($raw['elements'] ?? null) ? $raw['elements'] : [] as $element) { + if (is_array($element)) { + $elements[] = $element; + } + } + if (count($elements) === 0) { + throw new InvalidArgumentException('This whiteboard has no content to save as a canvas.', Http::STATUS_BAD_REQUEST); + } + + $files = []; + foreach (is_array($raw['files'] ?? null) ? $raw['files'] : [] as $key => $file) { + if (is_array($file)) { + $files[(string)$key] = $file; + } + } + + $data = [ + 'elements' => $elements, + 'files' => $files, + 'scrollToContent' => true, + ]; + + if (isset($raw['appState']) && is_array($raw['appState'])) { + $appState = $raw['appState']; + unset($appState['collaborators'], $appState['selectedElementIds']); + if (!empty($appState)) { + $data['appState'] = $appState; + } + } + + try { + $encoded = json_encode($data, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw new InvalidArgumentException('This is not a valid whiteboard file.', Http::STATUS_BAD_REQUEST); + } + if (strlen($encoded) > self::MAX_CANVAS_TEMPLATE_BYTES) { + throw new InvalidArgumentException('Canvas is too large (max 15 MB).', Http::STATUS_BAD_REQUEST); + } + + return $data; + } + + /** + * Write a canvas template file from canonical board data (see parseCanvasTemplateData). + * + * @param array $data + * + * @return array{name: string, scope: string} + * + * @throws NotPermittedException + * @throws NotFoundException + * @throws GenericFileException + */ + public function publishCanvasTemplate(string $uid, string $scope, string $name, array $data): array { + $scope = $this->folders->normalizeScope($scope); + $this->folders->assertScopeWritable($scope); + $name = $this->folders->normalizeName($name); + $this->folders->enforceFilenamePolicy($name, self::WHITEBOARD_EXTENSION); + + $folder = $scope === self::SCOPE_ORG + ? $this->getOrgCanvasTemplateFolder(true) + : $this->folders->getUserTemplateFolder($uid); + if (!$folder instanceof Folder) { + throw new NotFoundException('Canvas template folder not available'); + } + + $fileName = $name . self::WHITEBOARD_EXTENSION; + $file = $folder->nodeExists($fileName) ? $folder->get($fileName) : $folder->newFile($fileName); + if (!$file instanceof File) { + throw new GenericFileException('Failed to create or get canvas template: ' . $fileName); + } + // Personal canvas templates share the Templates folder with library + // pointer boards — never overwrite a library's pointer. + if ($this->folders->isLibraryPointer($file)) { + throw new InvalidArgumentException('A library named "' . $name . '" already exists. Choose a different name.', Http::STATUS_CONFLICT); + } + $file->putContent(json_encode($data, JSON_THROW_ON_ERROR)); + + return ['name' => $name, 'scope' => $scope]; + } + + /** + * @return array + */ + public function listOrgCanvasTemplates(): array { + $templates = []; + foreach ($this->getOrgCanvasTemplateFiles() as $file) { + $elementCount = 0; + try { + $content = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR); + if (is_array($content) && isset($content['elements']) && is_array($content['elements'])) { + $elementCount = count($content['elements']); + } + } catch (JsonException) { + $this->logger->warning('Skipping malformed whiteboard canvas template', [ + 'app' => 'whiteboard', + 'file' => $file->getName(), + ]); + } + $templates[] = [ + 'name' => $this->canvasTemplateNameFromFile($file->getName()), + 'elementCount' => $elementCount, + 'sizeBytes' => (int)$file->getSize(), + ]; + } + usort($templates, static fn (array $a, array $b): int => strcasecmp($a['name'], $b['name'])); + return $templates; + } + + /** + * @throws NotPermittedException + * @throws NotFoundException + */ + public function deleteOrgCanvasTemplate(string $name): void { + $name = $this->folders->normalizeName($name); + $this->folders->deleteIfExists($this->getOrgCanvasTemplateFolder(false), $name . self::WHITEBOARD_EXTENSION); + } + + /** + * Organization canvas template files for the picker. + * + * @return array + */ + public function getOrgCanvasTemplateFiles(): array { + $folder = $this->getOrgCanvasTemplateFolder(false); + if (!$folder instanceof Folder) { + return []; + } + $files = []; + foreach ($folder->getDirectoryListing() as $node) { + if ($node instanceof File && str_ends_with(strtolower($node->getName()), self::WHITEBOARD_EXTENSION)) { + $files[] = $node; + } + } + return $files; + } + + public function canvasTemplateNameFromFile(string $fileName): string { + return str_ends_with(strtolower($fileName), self::WHITEBOARD_EXTENSION) + ? substr($fileName, 0, -strlen(self::WHITEBOARD_EXTENSION)) + : $fileName; + } + + private function getOrgCanvasTemplateFolder(bool $create): ?Folder { + return $this->folders->getAppDataFolder(self::ORG_CANVAS_TEMPLATE_DIR, $create); + } +} diff --git a/lib/Service/WhiteboardContentService.php b/lib/Service/WhiteboardContentService.php index b67c8807..d3a04a30 100644 --- a/lib/Service/WhiteboardContentService.php +++ b/lib/Service/WhiteboardContentService.php @@ -167,6 +167,10 @@ private function normalizeIncomingData(array $incoming): array { : []; } + if (array_key_exists('libraryItems', $incoming) && is_array($incoming['libraryItems'])) { + $normalized['libraryItems'] = $this->sanitizeLibraryItems($incoming['libraryItems']); + } + if (array_key_exists('appState', $incoming) && is_array($incoming['appState'])) { $normalized['appState'] = $this->sanitizeAppState($incoming['appState']); } @@ -175,6 +179,18 @@ private function normalizeIncomingData(array $incoming): array { $normalized['scrollToContent'] = (bool)$incoming['scrollToContent']; } + if (array_key_exists('libraryRef', $incoming)) { + if ($incoming['libraryRef'] === null) { + // Explicit removal marker; mergeData() drops the stored ref. + $normalized['libraryRef'] = null; + } else { + $ref = $this->sanitizeLibraryRef($incoming['libraryRef']); + if ($ref !== null) { + $normalized['libraryRef'] = $ref; + } + } + } + return $normalized; } @@ -182,6 +198,10 @@ private function normalizeIncomingData(array $incoming): array { * @param array $payload */ private function isEffectivelyEmptyPayload(array $payload): bool { + if (array_key_exists('libraryItems', $payload)) { + return false; + } + $hasFiles = array_key_exists('files', $payload) && is_array($payload['files']) && !empty($payload['files']); @@ -211,7 +231,7 @@ private function isEffectivelyEmptyPayload(array $payload): bool { } foreach ($payload as $key => $_value) { - if (!in_array($key, ['elements', 'files', 'appState', 'scrollToContent'], true)) { + if (!in_array($key, ['elements', 'files', 'libraryItems', 'appState', 'scrollToContent'], true)) { return false; } } @@ -243,6 +263,10 @@ private function normalizeStoredData(array $stored): array { $normalized['files'] = $this->sanitizeFiles($stored['files']); } + if (array_key_exists('libraryItems', $stored) && is_array($stored['libraryItems'])) { + $normalized['libraryItems'] = $this->sanitizeLibraryItems($stored['libraryItems']); + } + if (array_key_exists('appState', $stored) && is_array($stored['appState'])) { $normalized['appState'] = $this->sanitizeAppState($stored['appState']); } elseif (array_key_exists('appState', $stored) && $stored['appState'] === null) { @@ -253,6 +277,13 @@ private function normalizeStoredData(array $stored): array { $normalized['scrollToContent'] = (bool)$stored['scrollToContent']; } + if (array_key_exists('libraryRef', $stored)) { + $ref = $this->sanitizeLibraryRef($stored['libraryRef']); + if ($ref !== null) { + $normalized['libraryRef'] = $ref; + } + } + return $normalized; } @@ -273,6 +304,10 @@ private function mergeData(array $current, array $incoming): array { $merged['files'] = $incoming['files']; } + if (array_key_exists('libraryItems', $incoming)) { + $merged['libraryItems'] = $incoming['libraryItems']; + } + if (array_key_exists('appState', $incoming)) { if ($incoming['appState'] === null) { unset($merged['appState']); @@ -285,6 +320,17 @@ private function mergeData(array $current, array $incoming): array { $merged['scrollToContent'] = (bool)$incoming['scrollToContent']; } + if (array_key_exists('libraryRef', $incoming)) { + if ($incoming['libraryRef'] === null) { + unset($merged['libraryRef']); + } else { + $ref = $this->sanitizeLibraryRef($incoming['libraryRef']); + if ($ref !== null) { + $merged['libraryRef'] = $ref; + } + } + } + return $merged; } @@ -330,6 +376,27 @@ private function sanitizeFiles(array $files): array { return $sanitized; } + /** + * @param array $items + * + * @return array + */ + private function sanitizeLibraryItems(array $items): array { + $sanitized = []; + + foreach ($items as $item) { + if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements']) || count($item['elements']) === 0) { + continue; + } + + unset($item['libraryName'], $item['scope'], $item['filename'], $item['basename']); + $item['elements'] = array_values($item['elements']); + $sanitized[] = $item; + } + + return $sanitized; + } + /** * @param array $appState * @@ -345,6 +412,27 @@ private function sanitizeAppState(array $appState): array { return $appState; } + /** + * A board references at most one library by {scope, name}; the live items + * are resolved on open, so updating the library propagates to every board. + * + * @param mixed $ref + * + * @return array{scope: string, name: string}|null + */ + private function sanitizeLibraryRef($ref): ?array { + if (!is_array($ref)) { + return null; + } + $scope = $ref['scope'] ?? null; + $name = $ref['name'] ?? null; + if (($scope === WhiteboardFolderService::SCOPE_PERSONAL || $scope === WhiteboardFolderService::SCOPE_ORG) + && is_string($name) && WhiteboardFolderService::isValidName($name)) { + return ['scope' => $scope, 'name' => $name]; + } + return null; + } + /** * @param mixed $value * diff --git a/lib/Service/WhiteboardFolderService.php b/lib/Service/WhiteboardFolderService.php new file mode 100644 index 00000000..cc5eb418 --- /dev/null +++ b/lib/Service/WhiteboardFolderService.php @@ -0,0 +1,224 @@ +isFilenameValid = $isFilenameValid; + } + + public function hasTemplateDirectory(): bool { + return $this->templateManager->hasTemplateDirectory(); + } + + /** + * @throws NotPermittedException + * @throws NotFoundException + */ + public function getUserTemplateFolder(string $uid): Folder { + if (!$this->templateManager->hasTemplateDirectory()) { + $this->templateManager->initializeTemplateDirectory(null, $uid, false); + } + + $userFolder = $this->rootFolder->getUserFolder($uid); + $templatesFolder = $userFolder->get($this->templateManager->getTemplatePath()); + if (!$templatesFolder instanceof Folder) { + throw new NotFoundException('Templates folder not found for user: ' . $uid); + } + return $templatesFolder; + } + + public function getAppDataFolder(string $dir, bool $create): ?Folder { + $instanceId = $this->config->getSystemValueString('instanceid', ''); + if ($instanceId === '') { + return null; + } + + if (!$create) { + try { + $node = $this->rootFolder->get('appdata_' . $instanceId . '/whiteboard/' . $dir); + } catch (NotFoundException) { + return null; + } catch (\Throwable $e) { + $this->logger->warning('Failed to access whiteboard app data', ['app' => 'whiteboard', 'dir' => $dir, 'exception' => $e]); + return null; + } + return $node instanceof Folder ? $node : null; + } + + $appDataRoot = $this->ensureChildFolder($this->rootFolder, 'appdata_' . $instanceId); + $appFolder = $this->ensureChildFolder($appDataRoot, 'whiteboard'); + return $this->ensureChildFolder($appFolder, $dir); + } + + /** + * Whether a ".whiteboard" file is a library pointer — an empty board whose + * only purpose is to carry a libraryRef (see WhiteboardLibraryService) — + * as opposed to a real board / canvas template. Library pointers and + * personal canvas templates share the user's Templates folder, so writers + * and deleters of one kind must not touch the other. + */ + public function isLibraryPointer(File $file): bool { + $raw = $file->getContent(); + try { + $content = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + // A just-created file is empty — that is the expected non-pointer + // case. Anything else is a corrupt file we are about to treat as + // an overwritable canvas template, so leave a trace. + if ($raw !== '') { + $this->logger->warning('Treating malformed whiteboard file as non-pointer', [ + 'app' => 'whiteboard', + 'file' => $file->getName(), + 'exception' => $e, + ]); + } + return false; + } + return is_array($content) + && isset($content['libraryRef']) && is_array($content['libraryRef']) + && ($content['elements'] ?? []) === []; + } + + public function deleteIfExists(?Folder $folder, string $fileName): void { + if (!$folder instanceof Folder || !$folder->nodeExists($fileName)) { + return; + } + $node = $folder->get($fileName); + if ($node instanceof File) { + $node->delete(); + } + } + + public function normalizeScope(string $scope): string { + if ($scope !== self::SCOPE_PERSONAL && $scope !== self::SCOPE_ORG) { + throw new InvalidArgumentException('Invalid scope', Http::STATUS_BAD_REQUEST); + } + return $scope; + } + + public function normalizeName(string $name): string { + $name = trim($name); + if (!self::isValidName($name)) { + throw new InvalidArgumentException( + strlen($name . self::LONGEST_EXTENSION) > self::MAX_FILENAME_BYTES ? 'Name is too long' : 'Invalid name', + Http::STATUS_BAD_REQUEST + ); + } + return $name; + } + + /** + * Enforce the instance filename policy (admin-forbidden characters, + * basenames, extensions) on the final filenames a save will create. + * Only write paths call this — resolving or deleting existing files must + * keep working even if the admin tightens the policy later. The validator + * API only exists on Nextcloud 30+. + */ + public function enforceFilenamePolicy(string $name, string ...$extensions): void { + if (!interface_exists(\OCP\Files\IFilenameValidator::class)) { + return; + } + $isFilenameValid = $this->isFilenameValid; + if ($isFilenameValid === null) { + $validator = \OCP\Server::get(\OCP\Files\IFilenameValidator::class); + $isFilenameValid = static fn (string $filename): bool => $validator->isFilenameValid($filename); + } + foreach ($extensions as $extension) { + if (!$isFilenameValid($name . $extension)) { + throw new InvalidArgumentException('Invalid name', Http::STATUS_BAD_REQUEST); + } + } + } + + /** + * Org templates only surface in the picker via the template provider API, + * which exists on Nextcloud 30+ — reject org writes on older servers so + * admins cannot create data nothing will ever show. + */ + public function assertScopeWritable(string $scope): void { + if ($scope === self::SCOPE_ORG && \OCP\Util::getVersion()[0] < 30) { + throw new InvalidArgumentException('Organization templates require Nextcloud 30 or later', Http::STATUS_BAD_REQUEST); + } + } + + /** + * Non-throwing twin of normalizeName, for callers that must drop invalid + * names silently (e.g. board content sanitization). + */ + public static function isValidName(string $name): bool { + if ($name !== trim($name) || $name === '' || $name === '.' || $name === '..') { + return false; + } + if (str_contains($name, '/') || str_contains($name, '\\') || preg_match('/[\x00-\x1F\x7F]/', $name) === 1) { + return false; + } + // Windows-reserved device names break syncing the Templates folder to + // Windows clients (reserved with or without an extension, any case). + if (preg_match('/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i', $name) === 1) { + return false; + } + return strlen($name . self::LONGEST_EXTENSION) <= self::MAX_FILENAME_BYTES; + } + + /** + * @throws NotPermittedException + */ + private function ensureChildFolder(Folder $folder, string $name): Folder { + if (!$folder->nodeExists($name)) { + $folder->newFolder($name); + } + $node = $folder->get($name); + if (!$node instanceof Folder) { + throw new RuntimeException('Expected folder at ' . $name); + } + return $node; + } +} diff --git a/lib/Service/WhiteboardLibraryService.php b/lib/Service/WhiteboardLibraryService.php index de27f938..215eca9d 100644 --- a/lib/Service/WhiteboardLibraryService.php +++ b/lib/Service/WhiteboardLibraryService.php @@ -9,70 +9,99 @@ namespace OCA\Whiteboard\Service; +use InvalidArgumentException; use JsonException; -use OCA\Whiteboard\AppInfo\Application; +use OCP\AppFramework\Http; use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\GenericFileException; -use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; -use OCP\Files\Template\ITemplateManager; +use OCP\IConfig; use OCP\Lock\LockedException; +use Psr\Log\LoggerInterface; +use RuntimeException; /** + * The Excalidraw palette ("My library"), plus the catalog of saved libraries. + * + * - "My library": the single writable palette per user, stored in the user's + * Templates folder as personal.excalidrawlib. Returned by getUserLib and the + * only thing written by updateUserLib. + * + * - Libraries: named shape kits a board can reference. A board stores only a + * {scope, name} reference (libraryRef); the live items are resolved on every + * open via resolveLibrary, so editing a library propagates to every board + * that points at it. Personal libraries live under the user's Templates + * folder; organization libraries live in app data and are shared with + * everyone. Each library has an items file (.excalidrawlib) and a + * pointer board file (.whiteboard holding the libraryRef) that + * surfaces it in the "New whiteboard" picker. + * * @psalm-suppress UndefinedDocblockClass * @psalm-suppress UndefinedClass * @psalm-suppress MissingDependency */ final class WhiteboardLibraryService { + private const PERSONAL_FILE = 'personal.excalidrawlib'; + private const LIBRARY_EXTENSION = '.excalidrawlib'; + private const WHITEBOARD_EXTENSION = '.whiteboard'; + private const PERSONAL_LIBRARY_DIR = '.whiteboard-libraries'; + private const ORG_LIBRARY_DIR = 'libraries'; + private const ORG_LIBRARY_POINTER_DIR = 'library-pointers'; + private const SCOPE_ORG = WhiteboardFolderService::SCOPE_ORG; + private const LEGACY_MIGRATED_FLAG = 'legacy_libraries_migrated'; + public function __construct( - private ITemplateManager $templateManager, - private IRootFolder $rootFolder, + private WhiteboardFolderService $folders, + private IConfig $config, + private LoggerInterface $logger, ) { } /** + * The writable personal library ("My library"). Returned as a single source. + * + * @return array> + * * @throws NotPermittedException + * @throws NotFoundException * @throws GenericFileException - * @throws LockedException - * @throws JsonException */ - public function getUserLib(): array { - // Get the .excalidrawlib files from the /Templates directory - $availableFileCreators = $this->templateManager->listTemplates(); - $templates = []; - $libs = []; - - foreach ($availableFileCreators as $fileCreator) { - if ($fileCreator['app'] !== Application::APP_ID) { - continue; - } - $templates = $fileCreator['templates']; - break; + public function getUserLib(string $uid): array { + if (str_starts_with($uid, 'shared_')) { + return []; } - foreach ($templates as $template) { - $templateDetails = $template->jsonSerialize(); - - if (str_ends_with($templateDetails['basename'], '.excalidrawlib')) { - $fileId = $templateDetails['fileid']; - $file = $this->rootFolder->getFirstNodeById($fileId); + $folder = $this->folders->getUserTemplateFolder($uid); + $this->migrateLegacyLibraries($uid, $folder); + if (!$folder->nodeExists(self::PERSONAL_FILE)) { + return []; + } - if (!$file instanceof File) { - continue; - } + $file = $folder->get(self::PERSONAL_FILE); + if (!$file instanceof File) { + return []; + } - $lib = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR); - $lib['basename'] = $templateDetails['basename']; - $libs[] = $lib; - } + $lib = $this->decodeLibrary($file); + if ($lib === null) { + return []; } - return $libs; + $lib['filename'] = self::PERSONAL_FILE; + $lib['basename'] = self::PERSONAL_FILE; + $lib['writable'] = true; + + return [$lib]; } /** + * Persist the writable personal library. Only personal-origin items reach + * here (the editor filters out read-only library items before sending). + * + * @param array $items + * * @throws NotPermittedException * @throws NotFoundException * @throws GenericFileException @@ -80,56 +109,447 @@ public function getUserLib(): array { * @throws JsonException */ public function updateUserLib(string $uid, array $items): void { - // Check if the user has a Templates folder, if not create one - if (!$this->templateManager->hasTemplateDirectory()) { - $this->templateManager->initializeTemplateDirectory(null, $uid, false); + if (str_starts_with($uid, 'shared_')) { + return; } - // Update the .excalidrawlib files in the Templates directory - $userFolder = $this->rootFolder->getUserFolder($uid); - $templatesPath = $this->templateManager->getTemplatePath(); - $templatesFolder = $userFolder->get($templatesPath); + $folder = $this->folders->getUserTemplateFolder($uid); + $this->writeLibraryFile($folder, self::PERSONAL_FILE, $this->sanitizeLibraryItems($items)); + } - if (!$templatesFolder instanceof Folder) { - throw new NotFoundException('Templates folder not found for user: ' . $uid); + /** + * The library catalog for the picker / save dialog. + * + * @return array{personal: array, org: array} + * + * @throws NotPermittedException + * @throws NotFoundException + */ + public function listLibraries(string $uid): array { + // Public-share sessions must not see any libraries, org included. + if (str_starts_with($uid, 'shared_')) { + return ['personal' => [], 'org' => []]; } - - $files = [ - 'personal.excalidrawlib' => [ - 'type' => 'excalidrawlib', - 'version' => 2, - 'libraryItems' => [], - ], + return [ + 'personal' => $this->listLibrariesIn($this->getPersonalLibraryFolder($uid)), + 'org' => $this->listLibrariesIn($this->getOrgLibraryFolder(false)), ]; + } - foreach ($items as $item) { - if (!isset($item['filename'])) { - $files['personal.excalidrawlib']['libraryItems'][] = $item; - } else { - if (isset($files[$item['filename']])) { - $files[$item['filename']]['libraryItems'][] = $item; - } else { - $files[$item['filename']] = [ - 'type' => 'excalidrawlib', - 'version' => 2, - 'libraryItems' => [$item], - ]; + /** + * Resolve a library reference to its current items (read-only section). + * Each item is tagged with `libraryName` = the library name. + * + * @return array + * + * @throws NotPermittedException + * @throws NotFoundException + */ + public function resolveLibrary(string $uid, string $scope, string $name): array { + // Public-share sessions must not resolve any libraries, org included. + if (str_starts_with($uid, 'shared_')) { + return []; + } + + $scope = $scope === self::SCOPE_ORG ? self::SCOPE_ORG : WhiteboardFolderService::SCOPE_PERSONAL; + $name = $this->folders->normalizeName($name); + $folder = $scope === self::SCOPE_ORG + ? $this->getOrgLibraryFolder(false) + : $this->getPersonalLibraryFolder($uid); + + if (!$folder instanceof Folder) { + return []; + } + + $itemsName = $name . self::LIBRARY_EXTENSION; + if (!$folder->nodeExists($itemsName)) { + return []; + } + $file = $folder->get($itemsName); + if (!$file instanceof File) { + return []; + } + + $lib = $this->decodeLibrary($file); + $items = is_array($lib) && isset($lib['libraryItems']) && is_array($lib['libraryItems']) ? $lib['libraryItems'] : []; + + $tagged = []; + foreach ($this->sanitizeLibraryItems($items) as $item) { + $item['libraryName'] = $name; + // Namespace the id: saved kits keep their source item ids, so a kit + // created from "My library" would otherwise collide with the + // originals in the editor's palette (one id, two sections). + $sourceId = isset($item['id']) && is_string($item['id']) && $item['id'] !== '' + ? $item['id'] + : bin2hex(random_bytes(6)); + $item['id'] = $scope . ':' . $name . ':' . $sourceId; + $tagged[] = $item; + } + return $tagged; + } + + /** + * Create or replace a saved library from library items. + * + * @param array $items + * + * @return array{name: string, scope: string} + * + * @throws NotPermittedException + * @throws NotFoundException + * @throws GenericFileException + */ + public function saveLibrary(string $uid, string $scope, string $name, array $items): array { + $scope = $this->folders->normalizeScope($scope); + $this->folders->assertScopeWritable($scope); + $name = $this->folders->normalizeName($name); + $this->folders->enforceFilenamePolicy($name, self::LIBRARY_EXTENSION, self::WHITEBOARD_EXTENSION); + $sanitized = $this->sanitizeLibraryItems($items); + if (count($sanitized) === 0) { + throw new InvalidArgumentException('Select at least one shape to save as a library', Http::STATUS_BAD_REQUEST); + } + if ($this->containsImageElement($sanitized)) { + throw new InvalidArgumentException('This library contains image items that cannot be imported by Whiteboard yet.', Http::STATUS_BAD_REQUEST); + } + + // Resolve and check the pointer slot before writing the items file, so + // a name conflict cannot leave an orphaned items file behind. + $pointerFolder = $scope === self::SCOPE_ORG + ? $this->getOrgLibraryPointerFolder(true) + : $this->folders->getUserTemplateFolder($uid); + if (!$pointerFolder instanceof Folder) { + throw new NotFoundException('Library pointer folder not available'); + } + $pointerName = $name . self::WHITEBOARD_EXTENSION; + if ($pointerFolder->nodeExists($pointerName)) { + $existing = $pointerFolder->get($pointerName); + if ($existing instanceof File && !$this->folders->isLibraryPointer($existing)) { + throw new InvalidArgumentException('A canvas template named "' . $name . '" already exists. Choose a different name.', Http::STATUS_CONFLICT); + } + } + + $folder = $scope === self::SCOPE_ORG + ? $this->getOrgLibraryFolder(true) + : $this->getPersonalLibraryFolder($uid, true); + if (!$folder instanceof Folder) { + throw new NotFoundException('Library folder not available'); + } + + $this->writeLibraryFile($folder, $name . self::LIBRARY_EXTENSION, $sanitized); + $this->writeLibraryPointer($pointerFolder, $scope, $name); + + return ['name' => $name, 'scope' => $scope]; + } + + /** + * @throws NotPermittedException + * @throws NotFoundException + */ + public function deleteLibrary(string $uid, string $scope, string $name): void { + $scope = $this->folders->normalizeScope($scope); + $name = $this->folders->normalizeName($name); + + $folder = $scope === self::SCOPE_ORG + ? $this->getOrgLibraryFolder(false) + : $this->getPersonalLibraryFolder($uid); + $this->folders->deleteIfExists($folder, $name . self::LIBRARY_EXTENSION); + + $pointerFolder = $scope === self::SCOPE_ORG + ? $this->getOrgLibraryPointerFolder(false) + : ($this->folders->hasTemplateDirectory() ? $this->folders->getUserTemplateFolder($uid) : null); + $pointerName = $name . self::WHITEBOARD_EXTENSION; + if ($pointerFolder instanceof Folder && $pointerFolder->nodeExists($pointerName)) { + $node = $pointerFolder->get($pointerName); + // A personal canvas template shares the pointer's path + // (Templates/.whiteboard) — only delete actual pointers. + if ($node instanceof File && $this->folders->isLibraryPointer($node)) { + $node->delete(); + } + } + } + + /** + * Pointer board files for the organization picker entries. + * + * @return array + */ + public function getOrgLibraryPointerFiles(): array { + $folder = $this->getOrgLibraryPointerFolder(false); + if (!$folder instanceof Folder) { + return []; + } + $files = []; + foreach ($folder->getDirectoryListing() as $node) { + if ($node instanceof File && $this->isWhiteboardFile($node->getName())) { + $files[] = $node; + } + } + return $files; + } + + public function libraryNameFromPointer(string $fileName): string { + return $this->isWhiteboardFile($fileName) + ? substr($fileName, 0, -strlen(self::WHITEBOARD_EXTENSION)) + : $fileName; + } + + /** + * Parse an uploaded .excalidrawlib file (v1 or v2) into library items. + * + * @return array + */ + public function parseLibraryFile(string $content): array { + try { + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw new InvalidArgumentException('This is not a valid library file.', Http::STATUS_BAD_REQUEST); + } + if (!is_array($data)) { + throw new InvalidArgumentException('This is not a valid library file.', Http::STATUS_BAD_REQUEST); + } + + $items = []; + if (isset($data['libraryItems']) && is_array($data['libraryItems'])) { + $items = $data['libraryItems']; + } elseif (isset($data['library']) && is_array($data['library'])) { + foreach ($data['library'] as $elements) { + if (is_array($elements)) { + $items[] = ['elements' => $elements]; + } + } + } + + $sanitized = $this->sanitizeLibraryItems($items); + if (count($sanitized) === 0) { + throw new InvalidArgumentException('This library file has no items.', Http::STATUS_BAD_REQUEST); + } + if ($this->containsImageElement($sanitized)) { + throw new InvalidArgumentException('This library contains image items that cannot be imported by Whiteboard yet.', Http::STATUS_BAD_REQUEST); + } + return $sanitized; + } + + // --------------------------------------------------------------------- + // internals + // --------------------------------------------------------------------- + + /** + * One-time merge of pre-1.6 library files into "My library". Older + * releases read every *.excalidrawlib in the Templates folder (the manual + * import flow for excalidraw.com kits); the new model only reads + * personal.excalidrawlib, so without this their items would silently + * disappear from the palette. The legacy files are left in place — only + * the migrated flag stops them from being merged again. + */ + private function migrateLegacyLibraries(string $uid, Folder $templatesFolder): void { + if ($this->config->getUserValue($uid, 'whiteboard', self::LEGACY_MIGRATED_FLAG, '') === '1') { + return; + } + + $legacyItems = []; + foreach ($templatesFolder->getDirectoryListing() as $node) { + if (!$node instanceof File + || !$this->isLibraryFile($node->getName()) + || $node->getName() === self::PERSONAL_FILE) { + continue; + } + $lib = $this->decodeLibrary($node); + if ($lib === null) { + continue; + } + $items = []; + if (isset($lib['libraryItems']) && is_array($lib['libraryItems'])) { + $items = $lib['libraryItems']; + } elseif (isset($lib['library']) && is_array($lib['library'])) { + // v1 files are bare element lists without item metadata. + foreach ($lib['library'] as $elements) { + if (is_array($elements)) { + $items[] = ['elements' => $elements]; + } + } + } + $legacyItems = array_merge($legacyItems, $this->sanitizeLibraryItems($items)); + } + + if ($legacyItems !== []) { + $existing = []; + if ($templatesFolder->nodeExists(self::PERSONAL_FILE)) { + $file = $templatesFolder->get(self::PERSONAL_FILE); + if ($file instanceof File) { + $lib = $this->decodeLibrary($file); + if (is_array($lib) && isset($lib['libraryItems']) && is_array($lib['libraryItems'])) { + $existing = $this->sanitizeLibraryItems($lib['libraryItems']); + } + } + } + $existingIds = []; + foreach ($existing as $item) { + if (isset($item['id']) && is_string($item['id'])) { + $existingIds[$item['id']] = true; } } + foreach ($legacyItems as $item) { + if (isset($item['id']) && is_string($item['id']) && isset($existingIds[$item['id']])) { + continue; + } + $existing[] = $item; + } + $this->writeLibraryFile($templatesFolder, self::PERSONAL_FILE, $existing); + $this->logger->info('Merged legacy whiteboard library files into personal library', [ + 'app' => 'whiteboard', + 'itemCount' => count($legacyItems), + ]); + } + + $this->config->setUserValue($uid, 'whiteboard', self::LEGACY_MIGRATED_FLAG, '1'); + } + + private function decodeLibrary(File $file): ?array { + try { + $lib = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $this->logger->warning('Skipping malformed whiteboard library', [ + 'app' => 'whiteboard', + 'file' => $file->getName(), + ]); + return null; + } + return is_array($lib) ? $lib : null; + } + + /** + * @param array $items + * + * @throws NotPermittedException + * @throws GenericFileException + */ + private function writeLibraryFile(Folder $folder, string $fileName, array $items): void { + $payload = ['type' => 'excalidrawlib', 'version' => 2, 'libraryItems' => array_values($items)]; + $file = $folder->nodeExists($fileName) ? $folder->get($fileName) : $folder->newFile($fileName); + if (!$file instanceof File) { + throw new GenericFileException('Failed to create or get file: ' . $fileName); + } + $file->putContent(json_encode($payload, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + } + + /** + * Write the pointer ".whiteboard" file that surfaces a library in the picker. + * + * @throws NotPermittedException + * @throws GenericFileException + */ + private function writeLibraryPointer(Folder $folder, string $scope, string $name): void { + $content = json_encode([ + 'elements' => [], + 'files' => [], + 'scrollToContent' => true, + 'libraryRef' => ['scope' => $scope, 'name' => $name], + ], JSON_THROW_ON_ERROR); + + $fileName = $name . self::WHITEBOARD_EXTENSION; + $file = $folder->nodeExists($fileName) ? $folder->get($fileName) : $folder->newFile($fileName); + if (!$file instanceof File) { + throw new GenericFileException('Failed to create or get pointer: ' . $fileName); + } + $file->putContent($content); + } + + /** + * @return array + */ + private function listLibrariesIn(?Folder $folder): array { + if (!$folder instanceof Folder) { + return []; + } + $libraries = []; + foreach ($folder->getDirectoryListing() as $node) { + if (!$node instanceof File || !$this->isLibraryFile($node->getName())) { + continue; + } + $lib = $this->decodeLibrary($node); + $itemCount = is_array($lib) && isset($lib['libraryItems']) && is_array($lib['libraryItems']) ? count($lib['libraryItems']) : 0; + $libraries[] = [ + 'name' => substr($node->getName(), 0, -strlen(self::LIBRARY_EXTENSION)), + 'itemCount' => $itemCount, + ]; } + usort($libraries, static fn (array $a, array $b): int => strcasecmp($a['name'], $b['name'])); + return $libraries; + } - foreach ($files as $filename => $fileData) { - if ($templatesFolder->nodeExists($filename)) { - $file = $templatesFolder->get($filename); - } else { - $file = $templatesFolder->newFile($filename); + /** + * Returns null when the folder does not exist yet and $create is false, + * so list/resolve/delete on a fresh account yield empty results, not 404s. + * + * @throws NotPermittedException + * @throws NotFoundException + */ + private function getPersonalLibraryFolder(string $uid, bool $create = false): ?Folder { + $templates = $this->folders->getUserTemplateFolder($uid); + if (!$templates->nodeExists(self::PERSONAL_LIBRARY_DIR)) { + if (!$create) { + return null; } + $templates->newFolder(self::PERSONAL_LIBRARY_DIR); + } + $node = $templates->get(self::PERSONAL_LIBRARY_DIR); + if (!$node instanceof Folder) { + throw new RuntimeException('Expected folder ' . self::PERSONAL_LIBRARY_DIR); + } + return $node; + } + + private function getOrgLibraryFolder(bool $create): ?Folder { + return $this->folders->getAppDataFolder(self::ORG_LIBRARY_DIR, $create); + } + + private function getOrgLibraryPointerFolder(bool $create): ?Folder { + return $this->folders->getAppDataFolder(self::ORG_LIBRARY_POINTER_DIR, $create); + } - if (!$file instanceof File) { - throw new GenericFileException('Failed to create or get file: ' . $filename); + /** + * @param array $items + * + * @return array + */ + private function sanitizeLibraryItems(array $items): array { + $sanitized = []; + foreach ($items as $item) { + if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements']) || count($item['elements']) === 0) { + continue; } + unset($item['filename'], $item['basename'], $item['name'], $item['writable'], $item['libraryName']); + $item['elements'] = array_values($item['elements']); + $sanitized[] = $item; + } + return $sanitized; + } - $file->putContent(json_encode($fileData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + /** + * Library files carry no binary file data, so image elements would render + * as permanently broken placeholders once resolved into a board. + * + * @param array $items + */ + private function containsImageElement(array $items): bool { + foreach ($items as $item) { + if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements'])) { + continue; + } + foreach ($item['elements'] as $element) { + if (is_array($element) && ($element['type'] ?? null) === 'image') { + return true; + } + } } + return false; + } + + private function isLibraryFile(string $fileName): bool { + return str_ends_with(strtolower($fileName), self::LIBRARY_EXTENSION); + } + + private function isWhiteboardFile(string $fileName): bool { + return str_ends_with(strtolower($fileName), self::WHITEBOARD_EXTENSION); } } diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index c386b7d4..50caba18 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -13,6 +13,7 @@ use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\Settings\ISettings; +use OCP\Util; class Admin implements ISettings { public function __construct( @@ -28,6 +29,9 @@ public function getForm(): TemplateResponse { $this->initialState->provideInitialState('secret', $this->configService->getWhiteboardSharedSecret()); $this->initialState->provideInitialState('jwt', $this->jwtService->generateJWTFromPayload([])); $this->initialState->provideInitialState('maxFileSize', $this->configService->getMaxFileSize()); + // Org templates surface in the picker via the template provider API, + // which only exists on Nextcloud 30+ (see AppInfo\Application). + $this->initialState->provideInitialState('orgTemplatesSupported', Util::getVersion()[0] >= 30); $response = new TemplateResponse( 'whiteboard', 'admin', diff --git a/lib/Template/GlobalTemplateProvider.php b/lib/Template/GlobalTemplateProvider.php new file mode 100644 index 00000000..a57d2fd5 --- /dev/null +++ b/lib/Template/GlobalTemplateProvider.php @@ -0,0 +1,105 @@ + new Template( + self::class, + self::LIBRARY_ID_PREFIX . $this->libraryService->libraryNameFromPointer($file->getName()), + $file + ), + $this->libraryService->getOrgLibraryPointerFiles() + ); + $templates = array_map( + fn (File $file): Template => new Template( + self::class, + self::CANVAS_TEMPLATE_ID_PREFIX . $this->canvasTemplateService->canvasTemplateNameFromFile($file->getName()), + $file + ), + $this->canvasTemplateService->getOrgCanvasTemplateFiles() + ); + return array_merge($templates, $libraries); + } catch (Exception $e) { + $this->logger->warning('Failed to list organization whiteboard canvas templates', ['exception' => $e]); + return []; + } + } + + /** + * @throws NotFoundException + */ + #[\Override] + public function getCustomTemplate(string $template): File { + if (str_starts_with($template, self::CANVAS_TEMPLATE_ID_PREFIX)) { + $name = substr($template, strlen(self::CANVAS_TEMPLATE_ID_PREFIX)); + foreach ($this->canvasTemplateService->getOrgCanvasTemplateFiles() as $file) { + if ($this->canvasTemplateService->canvasTemplateNameFromFile($file->getName()) === $name) { + return $file; + } + } + throw new NotFoundException('Organization canvas template not found'); + } + + $name = str_starts_with($template, self::LIBRARY_ID_PREFIX) + ? substr($template, strlen(self::LIBRARY_ID_PREFIX)) + : $template; + + foreach ($this->libraryService->getOrgLibraryPointerFiles() as $file) { + if ($this->libraryService->libraryNameFromPointer($file->getName()) === $name) { + return $file; + } + } + + throw new NotFoundException('Organization library not found'); + } +} diff --git a/package-lock.json b/package-lock.json index dbfde122..93b80502 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@nextcloud/capabilities": "^1.2.1", "@nextcloud/dialogs": "^6.4.1", "@nextcloud/event-bus": "^3.3.3", - "@nextcloud/excalidraw": "0.18.0-6135eb0", + "@nextcloud/excalidraw": "0.18.0-5b75b44", "@nextcloud/files": "^4.0.0", "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", @@ -415,6 +415,8 @@ }, "node_modules/@braintree/sanitize-url": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", + "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==", "license": "MIT" }, "node_modules/@buttercup/fetch": { @@ -1125,6 +1127,8 @@ }, "node_modules/@excalidraw/laser-pointer": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz", + "integrity": "sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==", "license": "MIT" }, "node_modules/@excalidraw/markdown-to-text": { @@ -1161,6 +1165,8 @@ }, "node_modules/@excalidraw/random-username": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@excalidraw/random-username/-/random-username-1.1.0.tgz", + "integrity": "sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==", "license": "MIT", "engines": { "node": ">=10" @@ -1175,25 +1181,31 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.4", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.2" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", @@ -1201,7 +1213,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@grpc/grpc-js": { @@ -2070,9 +2084,9 @@ } }, "node_modules/@nextcloud/excalidraw": { - "version": "0.18.0-6135eb0", - "resolved": "https://registry.npmjs.org/@nextcloud/excalidraw/-/excalidraw-0.18.0-6135eb0.tgz", - "integrity": "sha512-+yqCaBX/b5UBALS04qknYoDaEvV3PlNGwzYZ2HmRlRDJTaLRGZYgWbR6KbYGr8ogghhzQZELorMKKyAiilyNRw==", + "version": "0.18.0-5b75b44", + "resolved": "https://registry.npmjs.org/@nextcloud/excalidraw/-/excalidraw-0.18.0-5b75b44.tgz", + "integrity": "sha512-eHJeRn6UhxM+idT3Zsn1sKMISrFEqImmIejOytFdowxvTWiG8r22PMVJ0t3kET0d+Ekq5j9phLHL8i+8TzVQ3A==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "6.0.2", @@ -2114,6 +2128,9 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/@excalidraw/mermaid-to-excalidraw": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.2.tgz", + "integrity": "sha512-hAFv/TTIsOdoy0dL5v+oBd297SQ+Z88gZ5u99fCIFuEMHfQuPgLhU/ztKhFSTs7fISwVo6fizny/5oQRR3d4tQ==", + "license": "MIT", "dependencies": { "@excalidraw/markdown-to-text": "0.1.2", "mermaid": "10.9.3", @@ -2122,6 +2139,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/nanoid": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", "funding": [ { "type": "github", @@ -2138,6 +2157,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/@types/mdast": { "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", + "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", "license": "MIT", "dependencies": { "@types/unist": "^2" @@ -2145,10 +2166,14 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/@types/unist": { "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, "node_modules/@nextcloud/excalidraw/node_modules/dompurify": { "version": "3.1.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz", + "integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==", "license": "(MPL-2.0 OR Apache-2.0)" }, "node_modules/@nextcloud/excalidraw/node_modules/immutable": { @@ -2159,6 +2184,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/mdast-util-from-markdown": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz", + "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==", "license": "MIT", "dependencies": { "@types/mdast": "^3.0.0", @@ -2181,6 +2208,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/mdast-util-to-string": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", + "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", "license": "MIT", "dependencies": { "@types/mdast": "^3.0.0" @@ -2192,6 +2221,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/mermaid": { "version": "10.9.3", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-10.9.3.tgz", + "integrity": "sha512-V80X1isSEvAewIL3xhmz/rVmc27CVljcsbWxkxlWJWY/1kQa4XOABqpDl2qQLGKzpKm6WbTfUEKImBlUfFYArw==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^6.0.1", @@ -2218,6 +2249,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz", + "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==", "funding": [ { "type": "GitHub Sponsors", @@ -2251,6 +2284,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-core-commonmark": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz", + "integrity": "sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==", "funding": [ { "type": "GitHub Sponsors", @@ -2283,6 +2318,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-factory-destination": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz", + "integrity": "sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==", "funding": [ { "type": "GitHub Sponsors", @@ -2302,6 +2339,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-factory-label": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz", + "integrity": "sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==", "funding": [ { "type": "GitHub Sponsors", @@ -2322,6 +2361,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-factory-space": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", + "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", "funding": [ { "type": "GitHub Sponsors", @@ -2340,6 +2381,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-factory-title": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz", + "integrity": "sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==", "funding": [ { "type": "GitHub Sponsors", @@ -2360,6 +2403,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-factory-whitespace": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz", + "integrity": "sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==", "funding": [ { "type": "GitHub Sponsors", @@ -2380,6 +2425,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-util-character": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", + "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", "funding": [ { "type": "GitHub Sponsors", @@ -2398,6 +2445,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-util-chunked": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz", + "integrity": "sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==", "funding": [ { "type": "GitHub Sponsors", @@ -2415,6 +2464,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-util-classify-character": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz", + "integrity": "sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==", "funding": [ { "type": "GitHub Sponsors", @@ -2434,6 +2485,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-util-combine-extensions": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz", + "integrity": "sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==", "funding": [ { "type": "GitHub Sponsors", @@ -2452,6 +2505,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-util-decode-numeric-character-reference": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz", + "integrity": "sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==", "funding": [ { "type": "GitHub Sponsors", @@ -2469,6 +2524,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-util-decode-string": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz", + "integrity": "sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==", "funding": [ { "type": "GitHub Sponsors", @@ -2489,6 +2546,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-util-encode": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz", + "integrity": "sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==", "funding": [ { "type": "GitHub Sponsors", @@ -2503,6 +2562,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-util-html-tag-name": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz", + "integrity": "sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==", "funding": [ { "type": "GitHub Sponsors", @@ -2517,6 +2578,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-util-normalize-identifier": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz", + "integrity": "sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==", "funding": [ { "type": "GitHub Sponsors", @@ -2534,6 +2597,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-util-resolve-all": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz", + "integrity": "sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==", "funding": [ { "type": "GitHub Sponsors", @@ -2551,6 +2616,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-util-sanitize-uri": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz", + "integrity": "sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==", "funding": [ { "type": "GitHub Sponsors", @@ -2570,6 +2637,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-util-subtokenize": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz", + "integrity": "sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==", "funding": [ { "type": "GitHub Sponsors", @@ -2590,6 +2659,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-util-symbol": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", + "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", "funding": [ { "type": "GitHub Sponsors", @@ -2604,6 +2675,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/micromark-util-types": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", + "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", "funding": [ { "type": "GitHub Sponsors", @@ -2618,6 +2691,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/nanoid": { "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" @@ -2628,10 +2703,14 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/pako": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.3.tgz", + "integrity": "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==", "license": "(MIT AND Zlib)" }, "node_modules/@nextcloud/excalidraw/node_modules/sass": { "version": "1.51.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.51.0.tgz", + "integrity": "sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==", "license": "MIT", "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -2647,6 +2726,8 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/unist-util-stringify-position": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", + "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", "license": "MIT", "dependencies": { "@types/unist": "^2.0.0" @@ -2658,6 +2739,9 @@ }, "node_modules/@nextcloud/excalidraw/node_modules/uuid": { "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -3477,10 +3561,14 @@ }, "node_modules/@radix-ui/primitive": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", "license": "MIT" }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.0.2" @@ -3502,6 +3590,8 @@ }, "node_modules/@radix-ui/react-collection": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.1.tgz", + "integrity": "sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", @@ -3517,6 +3607,8 @@ }, "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-compose-refs": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10" @@ -3527,6 +3619,8 @@ }, "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10" @@ -3537,6 +3631,8 @@ }, "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", + "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", @@ -3549,6 +3645,8 @@ }, "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", @@ -3560,6 +3658,8 @@ }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3573,6 +3673,8 @@ }, "node_modules/@radix-ui/react-context": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3586,6 +3688,8 @@ }, "node_modules/@radix-ui/react-direction": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.0.tgz", + "integrity": "sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10" @@ -3596,6 +3700,8 @@ }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.1", @@ -3621,6 +3727,8 @@ }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3634,6 +3742,8 @@ }, "node_modules/@radix-ui/react-focus-scope": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", @@ -3657,6 +3767,8 @@ }, "node_modules/@radix-ui/react-id": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" @@ -3673,6 +3785,8 @@ }, "node_modules/@radix-ui/react-popover": { "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", + "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.1", @@ -3708,6 +3822,8 @@ }, "node_modules/@radix-ui/react-popper": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", @@ -3738,6 +3854,8 @@ }, "node_modules/@radix-ui/react-portal": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.0.2", @@ -3760,6 +3878,8 @@ }, "node_modules/@radix-ui/react-presence": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", @@ -3782,6 +3902,8 @@ }, "node_modules/@radix-ui/react-primitive": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.1.2" @@ -3803,6 +3925,8 @@ }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.2.tgz", + "integrity": "sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", @@ -3823,6 +3947,8 @@ }, "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", + "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10" @@ -3830,6 +3956,8 @@ }, "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10" @@ -3840,6 +3968,8 @@ }, "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10" @@ -3850,6 +3980,8 @@ }, "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-id": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", + "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", @@ -3861,6 +3993,8 @@ }, "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", + "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", @@ -3873,6 +4007,8 @@ }, "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", @@ -3884,6 +4020,8 @@ }, "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", + "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10" @@ -3894,6 +4032,8 @@ }, "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-controllable-state": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", + "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", @@ -3905,6 +4045,8 @@ }, "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-layout-effect": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", + "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10" @@ -3915,6 +4057,8 @@ }, "node_modules/@radix-ui/react-slot": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" @@ -3931,6 +4075,8 @@ }, "node_modules/@radix-ui/react-tabs": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.2.tgz", + "integrity": "sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", @@ -3950,6 +4096,8 @@ }, "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", + "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10" @@ -3957,6 +4105,8 @@ }, "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10" @@ -3967,6 +4117,8 @@ }, "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10" @@ -3977,6 +4129,8 @@ }, "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", + "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", @@ -3988,6 +4142,8 @@ }, "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.0.tgz", + "integrity": "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", @@ -4001,6 +4157,8 @@ }, "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", + "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", @@ -4013,6 +4171,8 @@ }, "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", @@ -4024,6 +4184,8 @@ }, "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", + "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10" @@ -4034,6 +4196,8 @@ }, "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", + "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", @@ -4045,6 +4209,8 @@ }, "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", + "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10" @@ -4055,6 +4221,8 @@ }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -4068,6 +4236,8 @@ }, "node_modules/@radix-ui/react-use-controllable-state": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", "license": "MIT", "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" @@ -4084,6 +4254,8 @@ }, "node_modules/@radix-ui/react-use-escape-keydown": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", "license": "MIT", "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" @@ -4100,6 +4272,8 @@ }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -4113,6 +4287,8 @@ }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", "license": "MIT", "dependencies": { "@radix-ui/rect": "1.1.0" @@ -4129,6 +4305,8 @@ }, "node_modules/@radix-ui/react-use-size": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" @@ -4145,6 +4323,8 @@ }, "node_modules/@radix-ui/rect": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", "license": "MIT" }, "node_modules/@redis/bloom": { @@ -6438,6 +6618,8 @@ }, "node_modules/aria-hidden": { "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -6890,6 +7072,8 @@ }, "node_modules/browser-fs-access": { "version": "0.29.1", + "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.29.1.tgz", + "integrity": "sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==", "license": "Apache-2.0" }, "node_modules/browser-resolve": { @@ -7276,6 +7460,8 @@ }, "node_modules/canvas-roundrect-polyfill": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/canvas-roundrect-polyfill/-/canvas-roundrect-polyfill-0.0.1.tgz", + "integrity": "sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==", "license": "MIT" }, "node_modules/chai": { @@ -7442,6 +7628,8 @@ }, "node_modules/clsx": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", + "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", "license": "MIT", "engines": { "node": ">=6" @@ -7676,6 +7864,8 @@ }, "node_modules/crc-32": { "version": "0.3.0", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-0.3.0.tgz", + "integrity": "sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==", "license": "Apache-2.0", "engines": { "node": ">=0.8" @@ -7727,6 +7917,8 @@ }, "node_modules/cross-env": { "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.1" @@ -8236,6 +8428,8 @@ }, "node_modules/dagre-d3-es": { "version": "7.0.10", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz", + "integrity": "sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A==", "license": "MIT", "dependencies": { "d3": "^7.8.2", @@ -8451,6 +8645,8 @@ }, "node_modules/detect-node-es": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, "node_modules/devlop": { @@ -8700,6 +8896,8 @@ }, "node_modules/elkjs": { "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", "license": "EPL-2.0" }, "node_modules/elliptic": { @@ -9031,6 +9229,8 @@ }, "node_modules/es6-promise-pool": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz", + "integrity": "sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10376,6 +10576,8 @@ }, "node_modules/fractional-indexing": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.2.0.tgz", + "integrity": "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==", "license": "CC0-1.0", "engines": { "node": "^14.13.1 || >=16.0.0" @@ -10462,6 +10664,8 @@ }, "node_modules/fuzzy": { "version": "0.1.3", + "resolved": "https://registry.npmjs.org/fuzzy/-/fuzzy-0.1.3.tgz", + "integrity": "sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==", "engines": { "node": ">= 0.6.0" } @@ -10511,6 +10715,8 @@ }, "node_modules/get-nonce": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", "license": "MIT", "engines": { "node": ">=6" @@ -10722,6 +10928,8 @@ }, "node_modules/glur": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glur/-/glur-1.1.2.tgz", + "integrity": "sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==", "license": "MIT" }, "node_modules/gopd": { @@ -11076,6 +11284,8 @@ }, "node_modules/image-blob-reduce": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/image-blob-reduce/-/image-blob-reduce-3.0.1.tgz", + "integrity": "sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==", "license": "MIT", "dependencies": { "pica": "^7.1.0" @@ -11752,6 +11962,8 @@ }, "node_modules/jotai": { "version": "2.11.0", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", + "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", "license": "MIT", "engines": { "node": ">=12.20.0" @@ -11771,6 +11983,8 @@ }, "node_modules/jotai-scope": { "version": "0.7.2", + "resolved": "https://registry.npmjs.org/jotai-scope/-/jotai-scope-0.7.2.tgz", + "integrity": "sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==", "license": "MIT", "peerDependencies": { "jotai": ">=2.9.2", @@ -11962,6 +12176,8 @@ }, "node_modules/kleur": { "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "license": "MIT", "engines": { "node": ">=6" @@ -12136,6 +12352,8 @@ }, "node_modules/lodash.debounce": { "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, "node_modules/lodash.includes": { @@ -13113,6 +13331,8 @@ }, "node_modules/mri": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", "license": "MIT", "engines": { "node": ">=4" @@ -13129,6 +13349,8 @@ }, "node_modules/multimath": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/multimath/-/multimath-2.0.0.tgz", + "integrity": "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==", "license": "MIT", "dependencies": { "glur": "^1.1.2", @@ -13370,6 +13592,8 @@ }, "node_modules/non-layered-tidy-tree-layout": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", + "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==", "license": "MIT" }, "node_modules/normalize-path": { @@ -13530,6 +13754,8 @@ }, "node_modules/open-color": { "version": "1.9.1", + "resolved": "https://registry.npmjs.org/open-color/-/open-color-1.9.1.tgz", + "integrity": "sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==", "license": "MIT" }, "node_modules/optimist": { @@ -13906,6 +14132,8 @@ }, "node_modules/perfect-freehand": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.0.tgz", + "integrity": "sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==", "license": "MIT" }, "node_modules/persist": { @@ -13923,6 +14151,8 @@ }, "node_modules/pica": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/pica/-/pica-7.1.1.tgz", + "integrity": "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==", "license": "MIT", "dependencies": { "glur": "^1.1.2", @@ -14028,10 +14258,14 @@ }, "node_modules/png-chunk-text": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunk-text/-/png-chunk-text-1.0.0.tgz", + "integrity": "sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==", "license": "MIT" }, "node_modules/png-chunks-encode": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-encode/-/png-chunks-encode-1.0.0.tgz", + "integrity": "sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==", "license": "MIT", "dependencies": { "crc-32": "^0.3.0", @@ -14040,6 +14274,8 @@ }, "node_modules/png-chunks-extract": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-extract/-/png-chunks-extract-1.0.0.tgz", + "integrity": "sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==", "license": "MIT", "dependencies": { "crc-32": "^0.3.0" @@ -14047,6 +14283,8 @@ }, "node_modules/points-on-curve": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-1.0.1.tgz", + "integrity": "sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==", "license": "MIT" }, "node_modules/points-on-path": { @@ -14477,6 +14715,8 @@ }, "node_modules/pwacompat": { "version": "2.0.17", + "resolved": "https://registry.npmjs.org/pwacompat/-/pwacompat-2.0.17.tgz", + "integrity": "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==", "license": "Apache-2.0" }, "node_modules/qs": { @@ -14629,7 +14869,9 @@ } }, "node_modules/react-remove-scroll": { - "version": "2.7.1", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", "license": "MIT", "dependencies": { "react-remove-scroll-bar": "^2.3.7", @@ -14653,6 +14895,8 @@ }, "node_modules/react-remove-scroll-bar": { "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", "license": "MIT", "dependencies": { "react-style-singleton": "^2.2.2", @@ -14673,6 +14917,8 @@ }, "node_modules/react-style-singleton": { "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", "license": "MIT", "dependencies": { "get-nonce": "^1.0.0", @@ -15294,6 +15540,8 @@ }, "node_modules/roughjs": { "version": "4.6.4", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.4.tgz", + "integrity": "sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==", "license": "MIT", "dependencies": { "hachure-fill": "^0.5.2", @@ -15304,6 +15552,8 @@ }, "node_modules/roughjs/node_modules/points-on-curve": { "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", "license": "MIT" }, "node_modules/router": { @@ -15357,6 +15607,8 @@ }, "node_modules/sade": { "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", "license": "MIT", "dependencies": { "mri": "^1.1.0" @@ -15761,6 +16013,9 @@ }, "node_modules/sliced": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz", + "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==", + "deprecated": "Unsupported", "license": "MIT" }, "node_modules/socket.io": { @@ -17049,6 +17304,8 @@ }, "node_modules/tunnel-rat": { "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", "license": "MIT", "dependencies": { "zustand": "^4.3.2" @@ -17056,6 +17313,8 @@ }, "node_modules/tunnel-rat/node_modules/zustand": { "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.2.2" @@ -17524,6 +17783,8 @@ }, "node_modules/use-callback-ref": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -17543,6 +17804,8 @@ }, "node_modules/use-sidecar": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", "license": "MIT", "dependencies": { "detect-node-es": "^1.1.0", @@ -17562,7 +17825,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -17600,6 +17865,8 @@ }, "node_modules/uvu": { "version": "0.5.6", + "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", + "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==", "license": "MIT", "dependencies": { "dequal": "^2.0.0", @@ -18094,6 +18361,8 @@ }, "node_modules/web-worker": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", "license": "Apache-2.0" }, "node_modules/webdav": { @@ -18173,6 +18442,8 @@ }, "node_modules/webworkify": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/webworkify/-/webworkify-1.5.0.tgz", + "integrity": "sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==", "license": "MIT" }, "node_modules/which": { diff --git a/package.json b/package.json index 48b14f88..861544de 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@nextcloud/capabilities": "^1.2.1", "@nextcloud/dialogs": "^6.4.1", "@nextcloud/event-bus": "^3.3.3", - "@nextcloud/excalidraw": "0.18.0-6135eb0", + "@nextcloud/excalidraw": "0.18.0-5b75b44", "@nextcloud/files": "^4.0.0", "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", diff --git a/src/App.tsx b/src/App.tsx index 73b67377..0a1477ea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,13 +5,13 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { getCurrentUser } from '@nextcloud/auth' import { translate as t } from '@nextcloud/l10n' import { loadState } from '@nextcloud/initial-state' import { Excalidraw as ExcalidrawComponent, useHandleLibrary, Sidebar, isElementLink } from '@nextcloud/excalidraw' import '@excalidraw/excalidraw/index.css' -import type { LibraryItems } from '@nextcloud/excalidraw/dist/types/excalidraw/types' +import type { ExcalidrawImperativeAPI, LibraryItems } from '@nextcloud/excalidraw/dist/types/excalidraw/types' import { useExcalidrawStore } from './stores/useExcalidrawStore' import { useWhiteboardConfigStore } from './stores/useWhiteboardConfigStore' import { useThemeHandling } from './hooks/useThemeHandling' @@ -27,7 +27,12 @@ import { AuthErrorNotification } from './components/AuthErrorNotification' import { useSync } from './hooks/useSync' import { useSyncStore } from './stores/useSyncStore' import { useLibrary } from './hooks/useLibrary' +import { useLibraryCatalog } from './hooks/useLibraryCatalog' +import { useCanvasTemplate } from './hooks/useCanvasTemplate' +import { SaveScopedDialog } from './components/SaveScopedDialog' +import type { SaveScope } from './components/SaveScopedDialog' import { useShallow } from 'zustand/react/shallow' +import { showError, showSuccess } from '@nextcloud/dialogs' import { useBoardDataManager } from './hooks/useBoardDataManager' import { useAssistant } from './hooks/useAssistant' import logger from './utils/logger' @@ -132,6 +137,18 @@ export default function App({ const { renderEmojiPicker } = useEmojiPicker() const { onChange: onChangeSync, onPointerUpdate } = useSync() const { fetchLibraryItems, updateLibraryItems, isLibraryLoaded, setIsLibraryLoaded } = useLibrary() + const { resolveLibrary, saveLibrary } = useLibraryCatalog() + const { publishCanvasTemplate } = useCanvasTemplate() + // Org saves need admin rights and a server (30+) whose picker can + // actually surface org templates. + const canSaveOrg = useMemo(() => loadState('whiteboard', 'isAdmin', false) + && loadState('whiteboard', 'orgTemplatesSupported', true), []) + const [libraryDialogOpen, setLibraryDialogOpen] = useState(false) + const [canvasTemplateDialogOpen, setCanvasTemplateDialogOpen] = useState(false) + const [saveDialogError, setSaveDialogError] = useState(null) + const [isSavingDialog, setIsSavingDialog] = useState(false) + // Items captured when "Save as library" is triggered from the library ⋯ menu. + const libraryItemsRef = useRef([]) useCollaboration() const { isReadOnly, refreshReadOnlyState } = useReadOnlyState() @@ -286,7 +303,10 @@ export default function App({ }, [handleExternalRestore, normalizedFileId]) // Use the board data manager hook - const { saveOnUnmount, isLoading } = useBoardDataManager() + const { + saveOnUnmount, + isLoading, + } = useBoardDataManager() useEffect(() => { if (!isLoading && loadState('whiteboard', 'directEditing', false)) { @@ -296,6 +316,8 @@ export default function App({ // Effect to handle fileId changes - cleanup previous board data useEffect(() => { + setIsLibraryLoaded(false) + // Clear any existing Excalidraw data when fileId changes if (excalidrawAPI) { excalidrawAPI.resetScene() @@ -310,32 +332,62 @@ export default function App({ saveOnUnmount() } } - }, [normalizedFileId, excalidrawAPI, resetInitialDataPromise, saveOnUnmount]) + }, [normalizedFileId, excalidrawAPI, resetInitialDataPromise, saveOnUnmount, setIsLibraryLoaded]) useEffect(() => { - resetInitialDataPromise() + if (isLoading || !excalidrawAPI) { + return + } // Fetch library items from the API window.name = fileName - const fetchLibInterval = setInterval(async () => { - const api = useExcalidrawStore.getState().excalidrawAPI - if (!api) { - logger.warn('[App] Excalidraw API not available, cannot update library') - return - } - clearInterval(fetchLibInterval) + setIsLibraryLoaded(false) + let cancelled = false + ;(async () => { try { - const libraryItems = await fetchLibraryItems() - await api.updateLibrary({ - libraryItems: libraryItems || [], + // Authoritative palette (merge:false): the writable "My library" + // (untagged) plus, if this board was created from a saved library, + // that library's CURRENT items resolved live (tagged libraryName -> + // rendered as a read-only section, never written back into My library). + const myLibrary = (await fetchLibraryItems()) ?? [] + const ref = useWhiteboardConfigStore.getState().libraryRef + let refItems: LibraryItems = [] + if (ref) { + try { + refItems = await resolveLibrary(ref.scope, ref.name) + } catch (error) { + // Board still opens with the personal library only. + showError(t('whiteboard', 'Could not load the library "{name}".', { name: ref.name })) + logger.error('[App] Error resolving library reference:', error) + } + } + if (cancelled) { + return + } + await excalidrawAPI.updateLibrary({ + libraryItems: [...myLibrary, ...refItems], + merge: false, }) setIsLibraryLoaded(true) } catch (error) { logger.error('[App] Error updating library items:', error) } - }, 1000) + })() - // On unmount: Clean up all stores to prevent stale state + return () => { + cancelled = true + } + }, [ + fileName, + fetchLibraryItems, + resolveLibrary, + isLoading, + excalidrawAPI, + normalizedFileId, + setIsLibraryLoaded, + ]) + + useEffect(() => { return () => { // Save any pending changes before resetting stores saveOnUnmount() @@ -347,7 +399,7 @@ export default function App({ // Terminate the worker terminateWorker() } - }, [resetInitialDataPromise, resetStore, resetExcalidrawAPI, terminateWorker, saveOnUnmount]) + }, [resetStore, resetExcalidrawAPI, terminateWorker, saveOnUnmount]) const [activeCommentThreadId, setActiveCommentThreadId] = useState(null) const [commentSidebarDocked, setCommentSidebarDocked] = useState(false) @@ -408,7 +460,116 @@ export default function App({ } catch (error) { logger.error('[App] Error syncing library items:', error) } - }, [isLibraryLoaded]) + }, [isLibraryLoaded, updateLibraryItems]) + + const handleLibrarySaveAs = useCallback((items: LibraryItems) => { + if (isReadOnly || isVersionPreview) { + return + } + libraryItemsRef.current = items ?? [] + setSaveDialogError(null) + setLibraryDialogOpen(true) + }, [isReadOnly, isVersionPreview]) + + const closeSaveDialogs = useCallback(() => { + setLibraryDialogOpen(false) + setCanvasTemplateDialogOpen(false) + setSaveDialogError(null) + }, []) + + const clearSaveDialogError = useCallback(() => { + setSaveDialogError(null) + }, []) + + const submitLibraryDialog = useCallback(async (scope: SaveScope, name: string) => { + if (name === '') { + setSaveDialogError(t('whiteboard', 'Enter a library name.')) + return + } + const items = libraryItemsRef.current ?? [] + if (items.length === 0) { + setSaveDialogError(t('whiteboard', 'Add at least one shape to your library first.')) + return + } + setIsSavingDialog(true) + setSaveDialogError(null) + try { + await saveLibrary(scope, name, items) + showSuccess(scope === 'org' + ? t('whiteboard', 'Organization library saved.') + : t('whiteboard', 'Library saved.')) + setLibraryDialogOpen(false) + } catch (error) { + const message = (error as { status?: number }).status === 409 + ? t('whiteboard', 'A canvas template with this name already exists. Choose a different name.') + : t('whiteboard', 'Could not save library.') + setSaveDialogError(message) + showError(message) + logger.error('[App] Error saving library:', error) + } finally { + setIsSavingDialog(false) + } + }, [saveLibrary]) + + const handleSaveAsCanvasTemplate = useCallback(() => { + if (isReadOnly || isVersionPreview) { + return + } + setSaveDialogError(null) + setCanvasTemplateDialogOpen(true) + }, [isReadOnly, isVersionPreview]) + + const submitCanvasTemplateDialog = useCallback(async (scope: SaveScope, name: string) => { + if (name === '') { + setSaveDialogError(t('whiteboard', 'Enter a canvas name.')) + return + } + const api = useExcalidrawStore.getState().excalidrawAPI + if (!api) { + return + } + const elements = api.getSceneElements() + if (elements.length === 0) { + setSaveDialogError(t('whiteboard', 'This whiteboard has no content to save as a canvas.')) + return + } + // Only ship file blobs still referenced by an image element. + const allFiles = api.getFiles() + const files: typeof allFiles = {} + for (const element of elements) { + const fileId = (element as { fileId?: string | null }).fileId + if (element.type === 'image' && fileId && allFiles[fileId]) { + files[fileId] = allFiles[fileId] + } + } + const data = { + elements, + files, + appState: { viewBackgroundColor: api.getAppState().viewBackgroundColor }, + } + if (JSON.stringify(data).length > 15 * 1024 * 1024) { + setSaveDialogError(t('whiteboard', 'Canvas is too large (max 15 MB).')) + return + } + setIsSavingDialog(true) + setSaveDialogError(null) + try { + await publishCanvasTemplate(scope, name, data) + showSuccess(scope === 'org' + ? t('whiteboard', 'Organization canvas published.') + : t('whiteboard', 'Canvas saved to your Templates folder.')) + setCanvasTemplateDialogOpen(false) + } catch (error) { + const message = (error as { status?: number }).status === 409 + ? t('whiteboard', 'A library with this name already exists. Choose a different name.') + : t('whiteboard', 'Could not save canvas.') + setSaveDialogError(message) + showError(message) + logger.error('[App] Error saving canvas template:', error) + } finally { + setIsSavingDialog(false) + } + }, [publishCanvasTemplate]) const libraryReturnUrl = encodeURIComponent(window.location.href) @@ -476,6 +637,15 @@ export default function App({ isVersionPreview ? 'App App--version-preview' : 'App' ), [isVersionPreview]) + const onExcalidrawAPI = useCallback((api: ExcalidrawImperativeAPI | null) => { + if (api) { + setExcalidrawAPI(api) + return + } + + resetExcalidrawAPI() + }, [resetExcalidrawAPI, setExcalidrawAPI]) + if (isLoading) { return (
@@ -531,7 +701,7 @@ export default function App({ validateEmbeddable={() => true} renderEmbeddable={Embeddable} beforeElementCreated={beforeElementCreated} - excalidrawAPI={setExcalidrawAPI} + excalidrawAPI={onExcalidrawAPI} initialData={initialDataPromise} generateIdForFile={generateIdForFile} onPointerUpdate={onPointerUpdate} @@ -548,6 +718,9 @@ export default function App({ onLibraryChange={onLibraryChange} langCode={lang} libraryReturnUrl={libraryReturnUrl} + libraryMenuTitle={t('whiteboard', 'My library')} + onLibrarySaveAs={(isReadOnly || isVersionPreview) ? undefined : handleLibrarySaveAs} + onSaveAsCanvasTemplate={(isReadOnly || isVersionPreview) ? undefined : handleSaveAsCanvasTemplate} > @@ -623,6 +796,32 @@ export default function App({ settings={creatorDisplaySettings} /> )} + {libraryDialogOpen && ( + + )} + {canvasTemplateDialogOpen && ( + + )}
) diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 54674475..f12c2e0d 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -49,6 +49,98 @@

+ + + {{ t('whiteboard', 'Organization templates require Nextcloud 30 or later. Upgrade to make them available in the "New whiteboard" picker.') }} + +
+

+ {{ t('whiteboard', 'Libraries') }} +

+

+ {{ t('whiteboard', 'Shape libraries shown to everyone as read-only sections in the whiteboard library panel. Upload an .excalidrawlib file; its shapes appear in every user’s library alongside their own.') }} +

+ + + {{ uploadingOrgLibrary ? t('whiteboard', 'Uploading…') : t('whiteboard', 'Upload library') }} + + + + + {{ t('whiteboard', 'Loading organization libraries…') }} + +

+ {{ t('whiteboard', 'No organization libraries yet. Upload an .excalidrawlib file to share a set of shapes with everyone.') }} +

+
    +
  • +
    + {{ library.name }} + {{ formatItemCount(library) }} +
    + + {{ t('whiteboard', 'Delete') }} + +
  • +
+
+
+

+ {{ t('whiteboard', 'Canvases') }} +

+

+ {{ t('whiteboard', 'Whiteboard canvases available to everyone in the "New whiteboard" picker. Upload a .whiteboard file; new boards created from it start with a copy of its content.') }} +

+ + + {{ uploadingOrgCanvasTemplate ? t('whiteboard', 'Uploading…') : t('whiteboard', 'Upload canvas') }} + + + + + {{ t('whiteboard', 'Loading organization canvases…') }} + +

+ {{ t('whiteboard', 'No organization canvases yet. Upload a .whiteboard file to share a starting board with everyone.') }} +

+
    +
  • +
    + {{ template.name }} + {{ formatElementCount(template) }} +
    + + {{ t('whiteboard', 'Delete') }} + +
  • +
+
+

diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index 03683b3d..22ebdd99 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -67,6 +67,7 @@ export default { this.saving = false } }, + t, }, } diff --git a/src/components/SaveScopedDialog.tsx b/src/components/SaveScopedDialog.tsx new file mode 100644 index 00000000..24f2b7cd --- /dev/null +++ b/src/components/SaveScopedDialog.tsx @@ -0,0 +1,97 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { useCallback, useState } from 'react' +import type { FormEvent } from 'react' +import { translate as t } from '@nextcloud/l10n' + +export type SaveScope = 'personal' | 'org' + +interface SaveScopedDialogProps { + title: string + hint: string + nameLabel: string + /** Admins get the personal/organization scope toggle. */ + isAdmin: boolean + isSaving: boolean + error: string | null + onClose: () => void + onSubmit: (scope: SaveScope, name: string) => void + /** Called when the user edits the name, to clear a stale error. */ + onErrorClear: () => void +} + +/** + * Name + scope form shared by "Save as library" and "Save as canvas template". + */ +export function SaveScopedDialog({ + title, + hint, + nameLabel, + isAdmin, + isSaving, + error, + onClose, + onSubmit, + onErrorClear, +}: SaveScopedDialogProps) { + const [scope, setScope] = useState('personal') + const [name, setName] = useState('') + + const close = useCallback(() => { + if (!isSaving) { + onClose() + } + }, [isSaving, onClose]) + + const submit = useCallback((event: FormEvent) => { + event.preventDefault() + if (!isSaving) { + onSubmit(scope, name.trim()) + } + }, [isSaving, onSubmit, scope, name]) + + return ( +

{ if (e.target === e.currentTarget) { close() } }}> +
+

{title}

+

{hint}

+ {isAdmin && ( +
+ {t('whiteboard', 'Available to')} + + +
+ )} + + { setName(e.target.value); onErrorClear() }} + /> + {error && ( +

{error}

+ )} +
+ + +
+
+
+ ) +} diff --git a/src/files.ts b/src/files.ts new file mode 100644 index 00000000..e9842e0c --- /dev/null +++ b/src/files.ts @@ -0,0 +1,233 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Groups the native "New whiteboard" template picker into two labelled + * sections — Personal (the blank board plus your own templates and saved + * libraries) and Organization (admin-published ones) — and badges each tile + * as Canvas template (full board copy) or Library (shape kit resolved live). + * + * Entry metadata comes from GET /apps/whiteboard/picker, a fileid -> {kind, + * scope} map; the blank tile (fileid -1) needs no lookup. + * + * Intentionally dependency-free: this script is injected standalone into the + * Files app, so importing @nextcloud/* (which drags in heavy shared chunks) + * risks a module-load failure. Uses fetch + NC globals only. + */ + +const WHITEBOARD_APP = 'whiteboard' + +type Scope = 'personal' | 'org' +type Kind = 'canvas-template' | 'library' +type Entry = { kind: Kind; scope: Scope } + +const ORDER: Scope[] = ['personal', 'org'] + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const oc = (): any => (globalThis as any).OC + +function tr(text: string): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const translate = (globalThis as any).t + return typeof translate === 'function' ? translate(WHITEBOARD_APP, text) : text +} + +function heading(scope: Scope): string { + return scope === 'personal' ? tr('Personal') : tr('Organization') +} + +function badgeLabel(kind: Kind): string { + return kind === 'library' ? tr('Library') : tr('Canvas') +} + +let entries: Map | null = null +let loadingEntries = false +// File ids that stayed unknown after a map refresh — don't refetch for them +// until the negative cache expires (a template saved after the last fetch +// becomes resolvable on the next picker open). +const unresolvedIds = new Set() +let unresolvedAt = 0 +const UNRESOLVED_RETRY_MS = 30_000 + +async function loadEntries(): Promise> { + const map = new Map() + try { + const OC = oc() + const url = `${OC?.webroot ?? ''}/index.php/apps/whiteboard/picker` + const response = await globalThis.fetch(url, { + headers: { + Accept: 'application/json', + requesttoken: OC?.requestToken ?? '', + }, + credentials: 'include', + }) + if (!response.ok) { + return map + } + const json = await response.json() + const raw = json?.entries ?? {} + for (const [fileid, entry] of Object.entries(raw)) { + const kind = (entry as Entry)?.kind === 'library' ? 'library' : 'canvas-template' + const scope = (entry as Entry)?.scope === 'org' ? 'org' : 'personal' + map.set(String(fileid), { kind, scope }) + } + } catch (error) { + // best-effort; leave the picker untouched on failure + } + return map +} + +const ID_PREFIX = 'template-picker-' + +function fileIdOf(item: Element): string | null { + const input = item.querySelector('input[type="radio"]') as HTMLInputElement | null + const id = input?.id + if (!id) { + return null + } + // e.g. "template-picker-1955" -> "1955", "template-picker--1" -> "-1". + return id.startsWith(ID_PREFIX) ? id.slice(ID_PREFIX.length) : id +} + +function scopeOf(fileid: string | null): Scope { + if (fileid === '-1') { + return 'personal' + } + return (fileid && entries?.get(fileid)?.scope) || 'personal' +} + +function kindOf(fileid: string | null): Kind | null { + if (fileid === '-1') { + return null + } + return (fileid && entries?.get(fileid)?.kind) || 'canvas-template' +} + +// Compact the tiles via INLINE styles — the component sizes previews with +// min/max-height: var(--height) and width: var(--width), which an injected +// stylesheet can't reliably beat. Inline styles win without !important. +function styleTile(item: HTMLElement, kind: Kind | null): void { + const preview = item.querySelector('.template-picker__preview') as HTMLElement | null + if (preview) { + preview.style.width = '100%' + preview.style.minHeight = '0' + preview.style.maxHeight = '76px' + preview.style.height = '76px' + preview.style.position = 'relative' + preview.querySelectorAll('.whiteboard-picker__badge').forEach((el) => el.remove()) + if (kind) { + const badge = document.createElement('span') + badge.className = 'whiteboard-picker__badge' + badge.textContent = badgeLabel(kind) + badge.style.cssText = 'position:absolute;top:4px;right:4px;padding:1px 6px;border-radius:10px;' + + 'font-size:.6875rem;font-weight:600;pointer-events:none;' + + 'background:var(--color-primary-element-light, #e2e6ff);' + + 'color:var(--color-primary-element-light-text, #333)' + preview.appendChild(badge) + } + } + const image = item.querySelector('.template-picker__image') as HTMLElement | null + if (image) { + image.style.width = 'auto' + image.style.height = 'auto' + image.style.maxWidth = '48px' + image.style.maxHeight = '48px' + image.style.margin = 'auto' + } + const title = item.querySelector('.template-picker__title') as HTMLElement | null + if (title) { + title.style.fontSize = '0.8125rem' + title.style.lineHeight = '1.2' + } +} + +function enhance(list: HTMLElement): void { + const items = Array.from(list.querySelectorAll('.template-picker__item')) as HTMLElement[] + if (items.length === 0) { + return + } + + // Only the whiteboard picker: at least one non-blank tile must be a known + // whiteboard entry. Otherwise this is some other file type's picker. + const isWhiteboard = items.some((item) => { + const id = fileIdOf(item) + return id !== null && id !== '-1' && !!entries?.has(id) + }) + if (!isWhiteboard) { + return + } + + const grouped: Record = { personal: [], org: [] } + for (const item of items) { + grouped[scopeOf(fileIdOf(item))].push(item) + } + + // Compact, left-aligned grid (inline so it beats the component's var-based grid). + list.style.gridTemplateColumns = 'repeat(auto-fill, 124px)' + list.style.gridAutoRows = 'auto' + list.style.gap = '2px 16px' + list.style.justifyContent = 'start' + list.style.maxWidth = 'none' + list.querySelectorAll('.whiteboard-picker__heading').forEach((el) => el.remove()) + + for (const scope of ORDER) { + const tiles = grouped[scope] + if (tiles.length === 0) { + continue + } + const header = document.createElement('li') + header.className = 'whiteboard-picker__heading' + header.textContent = heading(scope) + header.style.cssText = 'grid-column:1/-1;margin:14px 0 2px;font-weight:600;font-size:.9rem;color:var(--color-text-maxcontrast)' + list.appendChild(header) + for (const tile of tiles) { + styleTile(tile, kindOf(fileIdOf(tile))) + list.appendChild(tile) + } + } + + list.dataset.whiteboardEnhanced = '1' +} + +async function tick(): Promise { + const list = document.querySelector( + '.templates-picker__list:not([data-whiteboard-enhanced])', + ) as HTMLElement | null + if (!list || list.querySelectorAll('.template-picker__item').length === 0) { + return + } + if (loadingEntries) { + return + } + // (Re)load the map when it is missing or when the picker shows files saved + // after the last fetch — otherwise new entries get default kind/scope. + const ids = Array.from(list.querySelectorAll('.template-picker__item')) + .map(fileIdOf) + .filter((id): id is string => id !== null && id !== '-1') + const negativeCacheFresh = Date.now() - unresolvedAt < UNRESOLVED_RETRY_MS + const hasUnknown = ids.some((id) => !entries?.has(id) && !(negativeCacheFresh && unresolvedIds.has(id))) + if (!entries || hasUnknown) { + loadingEntries = true + entries = await loadEntries() + loadingEntries = false + unresolvedIds.clear() + for (const id of ids) { + if (!entries.has(id)) { + unresolvedIds.add(id) + } + } + unresolvedAt = Date.now() + } + try { + enhance(list) + } catch (error) { + // ignore – leave picker flat on failure + } +} + +const intervalId = globalThis.setInterval(() => { + tick().catch(() => {}) +}, 400) +globalThis.addEventListener('pagehide', () => { + globalThis.clearInterval(intervalId) +}) diff --git a/src/hooks/useBoardDataManager.ts b/src/hooks/useBoardDataManager.ts index 699c11b6..644ed1e6 100644 --- a/src/hooks/useBoardDataManager.ts +++ b/src/hooks/useBoardDataManager.ts @@ -27,6 +27,7 @@ export function useBoardDataManager() { fileId, resolveInitialData, resetInitialDataPromise, + setLibraryRef, isVersionPreview, versionSource, fileVersion, @@ -34,6 +35,7 @@ export function useBoardDataManager() { fileId: state.fileId, resolveInitialData: state.resolveInitialData, resetInitialDataPromise: state.resetInitialDataPromise, + setLibraryRef: state.setLibraryRef, isVersionPreview: state.isVersionPreview, versionSource: state.versionSource, fileVersion: state.fileVersion, @@ -187,6 +189,9 @@ export function useBoardDataManager() { return } + // The library (if any) this board was created from; resolved live in App. + setLibraryRef(serverData?.libraryRef ?? null) + let dataToUse = null if (serverData && serverData.elements && Array.isArray(serverData.elements)) { @@ -308,7 +313,7 @@ export function useBoardDataManager() { }, 50) loadingTimeoutsRef.current.add(timeout) } - }, [fileId, resolveInitialData, fetchDataFromServer, isVersionPreview, versionSource, fileVersion]) + }, [fileId, resolveInitialData, setLibraryRef, fetchDataFromServer, isVersionPreview, versionSource, fileVersion]) const saveOnUnmount = useCallback(() => { if (useWhiteboardConfigStore.getState().isVersionPreview) { diff --git a/src/hooks/useCanvasTemplate.ts b/src/hooks/useCanvasTemplate.ts new file mode 100644 index 00000000..55699078 --- /dev/null +++ b/src/hooks/useCanvasTemplate.ts @@ -0,0 +1,42 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { useCallback } from 'react' +import { useJWTStore } from '../stores/useJwtStore' +import { generateUrl } from '@nextcloud/router' +import type { BinaryFiles } from '@excalidraw/excalidraw/types/types' + +export interface CanvasTemplateData { + elements: readonly unknown[] + files: BinaryFiles + appState?: Record +} + +export function useCanvasTemplate() { + const getJWT = useJWTStore(state => state.getJWT) + + const publishCanvasTemplate = useCallback(async (scope: 'personal' | 'org', name: string, data: CanvasTemplateData): Promise => { + const jwt = await getJWT() + if (!jwt) { + throw new Error('No JWT') + } + const response = await globalThis.fetch(generateUrl('apps/whiteboard/canvas-template'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ scope, name, data }), + }) + if (!response.ok) { + const err = new Error(`Failed to publish template: ${response.statusText}`) as Error & { status?: number } + err.status = response.status + throw err + } + }, [getJWT]) + + return { publishCanvasTemplate } +} diff --git a/src/hooks/useLibrary.ts b/src/hooks/useLibrary.ts index 7721c7ab..7472aba3 100644 --- a/src/hooks/useLibrary.ts +++ b/src/hooks/useLibrary.ts @@ -12,6 +12,9 @@ import logger from '../utils/logger' type LibraryItemExtended = LibraryItem & { filename?: string; + // Set on items resolved from a saved/org library (read-only section in the + // editor); never persisted back into the personal library. + libraryName?: string; } export function useLibrary() { @@ -90,7 +93,7 @@ export function useLibrary() { logger.error('[Library] Error fetching library:', error) return null } - }) + }, [getJWT]) const updateLibraryItems = useCallback(async (items: LibraryItems): Promise => { try { @@ -99,6 +102,9 @@ export function useLibrary() { logger.warn('[Library] No JWT found, cannot update library') return } + // Persist only the writable "My library" items — items tagged with a + // libraryName belong to a read-only source resolved per board. + const itemsToSave = items.filter(item => !(item as LibraryItemExtended).libraryName) const url = generateUrl('apps/whiteboard/library') const response = await globalThis.fetch(url, { method: 'PUT', @@ -107,7 +113,7 @@ export function useLibrary() { 'X-Requested-With': 'XMLHttpRequest', Authorization: `Bearer ${jwt}`, }, - body: JSON.stringify({ items }), + body: JSON.stringify({ items: itemsToSave }), }) if (!response.ok) { @@ -116,7 +122,7 @@ export function useLibrary() { } catch (error) { logger.error('[Library] Error updating library:', error) } - }) + }, [getJWT]) return { fetchLibraryItems, diff --git a/src/hooks/useLibraryCatalog.ts b/src/hooks/useLibraryCatalog.ts new file mode 100644 index 00000000..6b25479b --- /dev/null +++ b/src/hooks/useLibraryCatalog.ts @@ -0,0 +1,103 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { useCallback } from 'react' +import { useJWTStore } from '../stores/useJwtStore' +import { generateUrl } from '@nextcloud/router' +import type { LibraryItems } from '@excalidraw/excalidraw/types/types' +import logger from '../utils/logger' + +export type LibraryScope = 'personal' | 'org' + +export type LibraryEntry = { name: string; itemCount: number } + +export type LibraryCatalog = { personal: LibraryEntry[]; org: LibraryEntry[] } + +const authHeaders = (jwt: string) => ({ + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + Authorization: `Bearer ${jwt}`, +}) + +export function useLibraryCatalog() { + const getJWT = useJWTStore(state => state.getJWT) + + const fetchLibraries = useCallback(async (): Promise => { + const empty: LibraryCatalog = { personal: [], org: [] } + try { + const jwt = await getJWT() + if (!jwt) { + return empty + } + const response = await globalThis.fetch(generateUrl('apps/whiteboard/libraries'), { + method: 'GET', + headers: authHeaders(jwt), + }) + if (!response.ok) { + throw new Error(`Failed to list libraries: ${response.statusText}`) + } + const json = await response.json() + const data = json?.data ?? {} + return { + personal: Array.isArray(data.personal) ? data.personal : [], + org: Array.isArray(data.org) ? data.org : [], + } + } catch (error) { + logger.error('[Library] Error listing libraries:', error) + return empty + } + }, [getJWT]) + + const resolveLibrary = useCallback(async (scope: string, name: string): Promise => { + const jwt = await getJWT() + if (!jwt) { + return [] + } + const url = `${generateUrl('apps/whiteboard/libraries/resolve')}?scope=${encodeURIComponent(scope)}&name=${encodeURIComponent(name)}` + const response = await globalThis.fetch(url, { + method: 'GET', + headers: authHeaders(jwt), + }) + if (!response.ok) { + throw new Error(`Failed to resolve library: ${response.statusText}`) + } + const json = await response.json() + return Array.isArray(json?.data) ? json.data : [] + }, [getJWT]) + + const saveLibrary = useCallback(async (scope: LibraryScope, name: string, items: LibraryItems): Promise => { + const jwt = await getJWT() + if (!jwt) { + throw new Error('No JWT') + } + const response = await globalThis.fetch(generateUrl('apps/whiteboard/libraries'), { + method: 'POST', + headers: authHeaders(jwt), + body: JSON.stringify({ scope, name, items }), + }) + if (!response.ok) { + const err = new Error(`Failed to save library: ${response.statusText}`) as Error & { status?: number } + err.status = response.status + throw err + } + }, [getJWT]) + + const deleteLibrary = useCallback(async (scope: LibraryScope, name: string): Promise => { + const jwt = await getJWT() + if (!jwt) { + throw new Error('No JWT') + } + const url = `${generateUrl('apps/whiteboard/libraries')}/${encodeURIComponent(scope)}/${encodeURIComponent(name)}` + const response = await globalThis.fetch(url, { + method: 'DELETE', + headers: authHeaders(jwt), + }) + if (!response.ok) { + throw new Error(`Failed to delete library: ${response.statusText}`) + } + }, [getJWT]) + + return { fetchLibraries, resolveLibrary, saveLibrary, deleteLibrary } +} diff --git a/src/stores/useWhiteboardConfigStore.ts b/src/stores/useWhiteboardConfigStore.ts index 3a726c4e..7fea574b 100644 --- a/src/stores/useWhiteboardConfigStore.ts +++ b/src/stores/useWhiteboardConfigStore.ts @@ -9,6 +9,8 @@ import type { ExcalidrawInitialDataState } from '@excalidraw/excalidraw/types/ty type InitialDataPromise = ReturnType +export type LibraryRef = { scope: string; name: string } + interface WhiteboardConfigState { // Core state fileId: number @@ -22,6 +24,7 @@ interface WhiteboardConfigState { isVersionPreview: boolean versionSource: string | null fileVersion: string | null + libraryRef: LibraryRef | null // library this board was created from (resolved live) // UI state zenModeEnabled: boolean @@ -46,6 +49,7 @@ interface WhiteboardConfigState { resolveInitialData: (data: ExcalidrawInitialDataState) => void resetInitialDataPromise: () => void resetStore: () => void // Reset the entire store state + setLibraryRef: (ref: LibraryRef | null) => void // UI actions setZenModeEnabled: (enabled: boolean) => void @@ -69,6 +73,7 @@ export const useWhiteboardConfigStore = create()((set, ge isVersionPreview: false, versionSource: null, fileVersion: null, + libraryRef: null, // UI state zenModeEnabled: false, @@ -126,11 +131,14 @@ export const useWhiteboardConfigStore = create()((set, ge isReadOnly: false, initialDataPromise: createResolvablePromise(), pendingInitialDataPromises: [], + libraryRef: null, zenModeEnabled: false, gridModeEnabled: false, }) }, + setLibraryRef: (ref: LibraryRef | null) => set({ libraryRef: ref }), + // UI actions setZenModeEnabled: (enabled: boolean) => set({ zenModeEnabled: enabled }), diff --git a/src/styles/globals/_layout.scss b/src/styles/globals/_layout.scss index 7e305384..d7625754 100644 --- a/src/styles/globals/_layout.scss +++ b/src/styles/globals/_layout.scss @@ -237,6 +237,120 @@ } } +.save-scoped-dialog__backdrop { + position: absolute; + inset: 0; + z-index: 100021; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(0, 0, 0, 0.35); +} + +.save-scoped-dialog { + display: flex; + flex-direction: column; + gap: 12px; + width: min(420px, 100%); + padding: 20px; + color: var(--color-main-text); + background: var(--color-main-background); + border-radius: var(--border-radius-large); + box-shadow: 0 4px 18px var(--color-box-shadow); + + h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + } + + p { + margin: 0; + } + + label { + font-weight: 600; + } + + input[type="text"] { + width: 100%; + min-height: 38px; + padding: 8px 10px; + color: var(--color-main-text); + background: var(--color-main-background); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + } +} + +.save-scoped-dialog__hint { + color: var(--color-text-maxcontrast); + line-height: 1.4; +} + +.save-scoped-dialog__scope { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + border: none; + + legend { + margin-bottom: 4px; + padding: 0; + font-weight: 600; + } +} + +.save-scoped-dialog__scope-option { + display: flex; + align-items: center; + gap: 8px; + font-weight: 400; + cursor: pointer; + + input[type="radio"] { + flex: none; + width: auto; + min-height: 0; + margin: 0; + } +} + +.save-scoped-dialog__error { + color: var(--color-text-error, var(--color-error, #d93025)); + font-weight: 600; +} + +.save-scoped-dialog__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +.save-scoped-dialog__button { + min-height: 34px; + padding: 6px 14px; + color: var(--color-main-text); + background: var(--color-background-hover); + border: none; + border-radius: var(--border-radius); + cursor: pointer; + + &:disabled { + cursor: default; + opacity: 0.6; + } +} + +.save-scoped-dialog__button--primary { + color: var(--color-primary-text); + background: var(--color-primary); +} + .version-preview-banner { position: absolute; top: calc(var(--default-grid-baseline) * 2); diff --git a/tests/Unit/AppInfo/ApplicationTest.php b/tests/Unit/AppInfo/ApplicationTest.php index ed1367c8..1f7fe636 100644 --- a/tests/Unit/AppInfo/ApplicationTest.php +++ b/tests/Unit/AppInfo/ApplicationTest.php @@ -9,10 +9,13 @@ namespace OCA\Whiteboard\AppInfo; +use OCP\AppFramework\Bootstrap\IRegistrationContext; + class ApplicationTest extends \Test\TestCase { public function testApp(): void { - $registrationContext = $this->createMock(\OCP\AppFramework\Bootstrap\IRegistrationContext::class); + $registrationContext = $this->createMock(IRegistrationContext::class); + $app = new Application(); $app->register($registrationContext); self::assertTrue(true); diff --git a/tests/Unit/Controller/PickerControllerTest.php b/tests/Unit/Controller/PickerControllerTest.php new file mode 100644 index 00000000..9be24bb5 --- /dev/null +++ b/tests/Unit/Controller/PickerControllerTest.php @@ -0,0 +1,135 @@ +createMock(File::class); + $file->method('getId')->willReturn($id); + $file->method('getName')->willReturn($name); + $file->method('getContent')->willReturn($content); + $file->method('getSize')->willReturn($size ?? strlen($content)); + return $file; + } + + /** @return Folder&MockObject */ + private function mockListingFolder(array $children): Folder { + $folder = $this->createMock(Folder::class); + $folder->method('getDirectoryListing')->willReturn(array_values($children)); + return $folder; + } + + public function testIndexMergesOrgAndPersonalEntries(): void { + $pointerContent = json_encode(['elements' => [], 'libraryRef' => ['scope' => 'org', 'name' => 'Kit']], JSON_THROW_ON_ERROR); + + $orgPointers = $this->mockListingFolder([ + $this->mockFile(11, 'Kit.whiteboard', $pointerContent), + ]); + $orgCanvasTemplates = $this->mockListingFolder([ + $this->mockFile(22, 'Kanban.whiteboard', json_encode(['elements' => [[]]], JSON_THROW_ON_ERROR)), + ]); + $userTemplates = $this->mockListingFolder([ + // Small pointer file -> library. + $this->mockFile(33, 'My kit.whiteboard', json_encode(['elements' => [], 'libraryRef' => ['scope' => 'personal', 'name' => 'My kit']], JSON_THROW_ON_ERROR)), + // Big file -> template, content never decoded. + $this->mockFile(44, 'Big board.whiteboard', '{}', 10 * 1024 * 1024), + // Small real board -> template. + $this->mockFile(55, 'Small board.whiteboard', json_encode(['elements' => [[]]], JSON_THROW_ON_ERROR)), + // Non-whiteboard files are ignored. + $this->mockFile(66, 'notes.txt', 'hello'), + ]); + + $templateManager = $this->createMock(ITemplateManager::class); + $templateManager->method('hasTemplateDirectory')->willReturn(true); + $templateManager->method('getTemplatePath')->willReturn('Templates/'); + + $userFolder = $this->createMock(Folder::class); + $userFolder->method('get')->with('Templates/')->willReturn($userTemplates); + + $rootFolder = $this->createMock(IRootFolder::class); + $rootFolder->method('getUserFolder')->with('alice')->willReturn($userFolder); + $rootFolder->method('get')->willReturnCallback(static function (string $path) use ($orgPointers, $orgCanvasTemplates) { + return match ($path) { + 'appdata_test/whiteboard/library-pointers' => $orgPointers, + 'appdata_test/whiteboard/templates' => $orgCanvasTemplates, + default => throw new NotFoundException($path), + }; + }); + + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueString')->with('instanceid', '')->willReturn('test'); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser')->willReturn($user); + + $logger = $this->createMock(LoggerInterface::class); + $folders = new WhiteboardFolderService($templateManager, $rootFolder, $config, $logger); + $controller = new PickerController( + $this->createMock(IRequest::class), + $userSession, + new WhiteboardLibraryService($folders, $config, $logger), + new CanvasTemplateService($folders, $logger), + $folders, + $logger, + ); + + $entries = $controller->index()->getData()['entries']; + + $this->assertSame(['kind' => 'library', 'scope' => 'org'], $entries['11']); + $this->assertSame(['kind' => 'canvas-template', 'scope' => 'org'], $entries['22']); + $this->assertSame(['kind' => 'library', 'scope' => 'personal'], $entries['33']); + $this->assertSame(['kind' => 'canvas-template', 'scope' => 'personal'], $entries['44']); + $this->assertSame(['kind' => 'canvas-template', 'scope' => 'personal'], $entries['55']); + $this->assertArrayNotHasKey('66', $entries); + } + + public function testIndexIsEmptyAndSafeWithoutSessionUserAndAppData(): void { + $rootFolder = $this->createMock(IRootFolder::class); + $rootFolder->method('get')->willReturnCallback(static fn (string $path) => throw new NotFoundException($path)); + + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueString')->with('instanceid', '')->willReturn('test'); + + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser')->willReturn(null); + + $logger = $this->createMock(LoggerInterface::class); + $folders = new WhiteboardFolderService($this->createMock(ITemplateManager::class), $rootFolder, $config, $logger); + $controller = new PickerController( + $this->createMock(IRequest::class), + $userSession, + new WhiteboardLibraryService($folders, $config, $logger), + new CanvasTemplateService($folders, $logger), + $folders, + $logger, + ); + + $this->assertSame([], $controller->index()->getData()['entries']); + } +} diff --git a/tests/Unit/Controller/WhiteboardControllerTest.php b/tests/Unit/Controller/WhiteboardControllerTest.php new file mode 100644 index 00000000..fafbd028 --- /dev/null +++ b/tests/Unit/Controller/WhiteboardControllerTest.php @@ -0,0 +1,179 @@ + File mock. */ + private array $templatesChildren = []; + + private function makeController(bool $isAdmin, array $params): WhiteboardController { + $request = $this->createMock(IRequest::class); + $jwt = JWT::encode(['userid' => self::UID, 'exp' => time() + 300], self::SECRET, JWTConsts::JWT_ALGORITHM); + $request->method('getHeader')->willReturnCallback(static fn (string $name): string => $name === 'Authorization' ? 'Bearer ' . $jwt : ''); + $request->method('getParam')->willReturnCallback(static fn (string $key, $default = null) => $params[$key] ?? $default); + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getAppValueString')->willReturn(self::SECRET); + $configService = new ConfigService($appConfig, $this->createMock(IConfig::class)); + + $templateManager = $this->createMock(ITemplateManager::class); + $templateManager->method('hasTemplateDirectory')->willReturn(true); + $templateManager->method('getTemplatePath')->willReturn('Templates/'); + + $templatesFolder = $this->createMock(Folder::class); + $templatesFolder->method('nodeExists')->willReturnCallback(fn (string $name): bool => isset($this->templatesChildren[$name])); + $templatesFolder->method('get')->willReturnCallback(fn (string $name) => $this->templatesChildren[$name]); + $templatesFolder->method('newFile')->willReturnCallback(function (string $name) { + $file = $this->createMock(File::class); + $file->method('getName')->willReturn($name); + $file->method('getContent')->willReturn(''); + $this->templatesChildren[$name] = $file; + return $file; + }); + $templatesFolder->method('newFolder')->willReturnCallback(function (string $name) { + $folder = $this->createMock(Folder::class); + $folder->method('newFile')->willReturnCallback(function (string $fileName) { + $file = $this->createMock(File::class); + $file->method('getName')->willReturn($fileName); + return $file; + }); + $this->templatesChildren[$name] = $folder; + return $folder; + }); + + $userFolder = $this->createMock(Folder::class); + $userFolder->method('get')->with('Templates/')->willReturn($templatesFolder); + + $rootFolder = $this->createMock(IRootFolder::class); + $rootFolder->method('getUserFolder')->with(self::UID)->willReturn($userFolder); + + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueString')->with('instanceid', '')->willReturn('test'); + + $logger = $this->createMock(LoggerInterface::class); + $folders = new WhiteboardFolderService($templateManager, $rootFolder, $config, $logger, static fn (): bool => true); + + $cache = $this->createMock(IMemcache::class); + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createLocking')->willReturn($cache); + + $groupManager = $this->createMock(IGroupManager::class); + $groupManager->method('isAdmin')->with(self::UID)->willReturn($isAdmin); + + return new WhiteboardController( + 'whiteboard', + $request, + new GetUserFromIdServiceFactory( + $this->createMock(ShareManager::class), + $this->createMock(IUserManager::class), + $this->createMock(IUserSession::class), + ), + new GetFileServiceFactory($rootFolder, $this->createMock(ShareManager::class), $logger), + new JWTService($configService), + new WhiteboardContentService($logger), + new WhiteboardLibraryService($folders, $config, $logger), + new CanvasTemplateService($folders, $logger), + new ExceptionService($logger), + $configService, + $logger, + $cacheFactory, + $groupManager, + ); + } + + public function testSaveLibraryOrgScopeForbiddenForNonAdmin(): void { + $controller = $this->makeController(false, [ + 'scope' => 'org', + 'name' => 'Kit', + 'items' => [['elements' => [['type' => 'rectangle']]]], + ]); + + $response = $controller->saveLibrary(); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + } + + public function testDeleteLibraryOrgScopeForbiddenForNonAdmin(): void { + $controller = $this->makeController(false, []); + + $response = $controller->deleteLibrary('org', 'Kit'); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + } + + public function testPublishTemplateOrgScopeForbiddenForNonAdmin(): void { + $controller = $this->makeController(false, [ + 'scope' => 'org', + 'name' => 'Kanban', + 'data' => ['elements' => [['type' => 'rectangle']]], + ]); + + $response = $controller->publishCanvasTemplate(); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + } + + public function testPublishTemplatePersonalCreatesTemplateFile(): void { + $controller = $this->makeController(false, [ + 'scope' => 'personal', + 'name' => 'Retro board', + 'data' => ['elements' => [['type' => 'rectangle']]], + ]); + + $response = $controller->publishCanvasTemplate(); + + $this->assertSame(Http::STATUS_CREATED, $response->getStatus()); + $this->assertSame(['name' => 'Retro board', 'scope' => 'personal'], $response->getData()['canvasTemplate']); + $this->assertArrayHasKey('Retro board.whiteboard', $this->templatesChildren); + } + + public function testPublishTemplateRejectsNonArrayData(): void { + $controller = $this->makeController(true, [ + 'scope' => 'personal', + 'name' => 'Bad', + 'data' => 'not-an-array', + ]); + + $response = $controller->publishCanvasTemplate(); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + } +} diff --git a/tests/Unit/Service/CanvasTemplateServiceTest.php b/tests/Unit/Service/CanvasTemplateServiceTest.php new file mode 100644 index 00000000..24e5a2de --- /dev/null +++ b/tests/Unit/Service/CanvasTemplateServiceTest.php @@ -0,0 +1,260 @@ + node mock. */ + private array $templatesChildren = []; + /** Children of appdata whiteboard/templates, name => node mock. */ + private array $orgCanvasTemplates = []; + private bool $orgCanvasTemplatesDirExists = false; + + protected function setUp(): void { + parent::setUp(); + + $templateManager = $this->createMock(ITemplateManager::class); + $templateManager->method('hasTemplateDirectory')->willReturn(true); + $templateManager->method('getTemplatePath')->willReturn('Templates/'); + + $templatesFolder = $this->mockFolder($this->templatesChildren); + $userFolder = $this->createMock(Folder::class); + $userFolder->method('get')->with('Templates/')->willReturn($templatesFolder); + + $orgCanvasTemplatesFolder = $this->mockFolder($this->orgCanvasTemplates); + + $whiteboardFolder = $this->createMock(Folder::class); + $whiteboardFolder->method('nodeExists')->with('templates')->willReturnCallback(fn (): bool => $this->orgCanvasTemplatesDirExists); + $whiteboardFolder->method('get')->with('templates')->willReturn($orgCanvasTemplatesFolder); + $whiteboardFolder->method('newFolder')->willReturnCallback(function () use ($orgCanvasTemplatesFolder): Folder { + $this->orgCanvasTemplatesDirExists = true; + return $orgCanvasTemplatesFolder; + }); + + $appDataRoot = $this->createMock(Folder::class); + $appDataRoot->method('nodeExists')->with('whiteboard')->willReturn(true); + $appDataRoot->method('get')->with('whiteboard')->willReturn($whiteboardFolder); + + $rootFolder = $this->createMock(IRootFolder::class); + $rootFolder->method('getUserFolder')->with(self::UID)->willReturn($userFolder); + $rootFolder->method('nodeExists')->willReturnCallback(static fn (string $n): bool => $n === 'appdata_test'); + $rootFolder->method('get')->willReturnCallback(function (string $path) use ($appDataRoot, $orgCanvasTemplatesFolder) { + if ($path === 'appdata_test') { + return $appDataRoot; + } + if ($path === 'appdata_test/whiteboard/templates' && $this->orgCanvasTemplatesDirExists) { + return $orgCanvasTemplatesFolder; + } + throw new \OCP\Files\NotFoundException($path); + }); + + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueString')->with('instanceid', '')->willReturn('test'); + + $logger = $this->createMock(LoggerInterface::class); + $folders = new WhiteboardFolderService($templateManager, $rootFolder, $config, $logger, static fn (): bool => true); + $this->service = new CanvasTemplateService($folders, $logger); + } + + /** @return Folder&MockObject */ + private function mockFolder(array &$children): Folder { + $folder = $this->createMock(Folder::class); + $folder->method('nodeExists')->willReturnCallback(static function (string $name) use (&$children): bool { + return isset($children[$name]); + }); + $folder->method('get')->willReturnCallback(static function (string $name) use (&$children) { + if (!isset($children[$name])) { + throw new \OCP\Files\NotFoundException($name); + } + return $children[$name]; + }); + $folder->method('getDirectoryListing')->willReturnCallback(static function () use (&$children): array { + return array_values($children); + }); + $folder->method('newFile')->willReturnCallback(function (string $name) use (&$children) { + $children[$name] = $this->mockWritableFile($name); + return $children[$name]; + }); + return $folder; + } + + /** @return File&MockObject */ + private function mockWritableFile(string $name, string $content = ''): File { + $state = new \stdClass(); + $state->content = $content; + $file = $this->createMock(File::class); + $file->method('getName')->willReturn($name); + $file->method('getSize')->willReturnCallback(static fn (): int => strlen($state->content)); + $file->method('putContent')->willReturnCallback(static function ($newContent) use ($state): void { + $state->content = $newContent; + }); + $file->method('getContent')->willReturnCallback(static fn (): string => $state->content); + return $file; + } + + // ------------------------------------------------------------------- + // parseCanvasTemplateData + // ------------------------------------------------------------------- + + public function testParseCanvasTemplateDataCanonicalizesBoard(): void { + $data = $this->service->parseCanvasTemplateData([ + 'elements' => [['type' => 'rectangle'], 'not-an-element'], + 'files' => ['f1' => ['mimeType' => 'image/png'], 'f2' => null], + 'appState' => ['viewBackgroundColor' => '#fff', 'collaborators' => ['x'], 'selectedElementIds' => ['y']], + 'libraryRef' => ['scope' => 'personal', 'name' => 'Kit'], + 'scrollToContent' => false, + ]); + + $this->assertSame([['type' => 'rectangle']], $data['elements']); + $this->assertSame(['f1' => ['mimeType' => 'image/png']], $data['files']); + $this->assertTrue($data['scrollToContent']); + $this->assertSame(['viewBackgroundColor' => '#fff'], $data['appState']); + $this->assertArrayNotHasKey('libraryRef', $data); + } + + public function testParseCanvasTemplateDataAcceptsJsonString(): void { + $data = $this->service->parseCanvasTemplateData(json_encode(['elements' => [['type' => 'ellipse']]], JSON_THROW_ON_ERROR)); + $this->assertCount(1, $data['elements']); + } + + public function testParseCanvasTemplateDataRejectsEmptyElements(): void { + $this->expectException(InvalidArgumentException::class); + $this->service->parseCanvasTemplateData(['elements' => []]); + } + + public function testParseCanvasTemplateDataRejectsInvalidJson(): void { + $this->expectException(InvalidArgumentException::class); + $this->service->parseCanvasTemplateData('not json'); + } + + public function testParseCanvasTemplateDataRejectsOversizedPayload(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('too large'); + $this->service->parseCanvasTemplateData(str_repeat('x', CanvasTemplateService::MAX_CANVAS_TEMPLATE_BYTES + 1)); + } + + // ------------------------------------------------------------------- + // publishCanvasTemplate + // ------------------------------------------------------------------- + + public function testPublishTemplatePersonalWritesIntoTemplatesFolder(): void { + $result = $this->service->publishCanvasTemplate(self::UID, 'personal', 'Retro board', [ + 'elements' => [['type' => 'rectangle']], + 'files' => [], + 'scrollToContent' => true, + ]); + + $this->assertSame(['name' => 'Retro board', 'scope' => 'personal'], $result); + $content = json_decode($this->templatesChildren['Retro board.whiteboard']->getContent(), true); + $this->assertSame([['type' => 'rectangle']], $content['elements']); + } + + public function testPublishTemplateOrgWritesIntoAppData(): void { + $this->service->publishCanvasTemplate(self::UID, 'org', 'Kanban', [ + 'elements' => [['type' => 'rectangle']], + 'files' => [], + 'scrollToContent' => true, + ]); + + $this->assertArrayHasKey('Kanban.whiteboard', $this->orgCanvasTemplates); + } + + public function testPublishTemplateRejectsInvalidScope(): void { + $this->expectException(InvalidArgumentException::class); + $this->service->publishCanvasTemplate(self::UID, 'global', 'Name', ['elements' => [[]]]); + } + + public function testPublishTemplateConflictsWithLibraryPointerOfSameName(): void { + $pointerJson = json_encode([ + 'elements' => [], + 'files' => [], + 'scrollToContent' => true, + 'libraryRef' => ['scope' => 'personal', 'name' => 'Kit'], + ], JSON_THROW_ON_ERROR); + $this->templatesChildren['Kit.whiteboard'] = $this->mockWritableFile('Kit.whiteboard', $pointerJson); + + try { + $this->service->publishCanvasTemplate(self::UID, 'personal', 'Kit', [ + 'elements' => [['type' => 'rectangle']], + 'files' => [], + 'scrollToContent' => true, + ]); + $this->fail('Expected a conflict'); + } catch (InvalidArgumentException $e) { + $this->assertSame(\OCP\AppFramework\Http::STATUS_CONFLICT, $e->getCode()); + } + + // The library pointer was not touched. + $this->assertSame($pointerJson, $this->templatesChildren['Kit.whiteboard']->getContent()); + } + + public function testPublishTemplateOverwritesExistingCanvasTemplate(): void { + $this->templatesChildren['Retro board.whiteboard'] = $this->mockWritableFile( + 'Retro board.whiteboard', + json_encode(['elements' => [['type' => 'ellipse']], 'files' => []], JSON_THROW_ON_ERROR) + ); + + $this->service->publishCanvasTemplate(self::UID, 'personal', 'Retro board', [ + 'elements' => [['type' => 'rectangle']], + 'files' => [], + 'scrollToContent' => true, + ]); + + $content = json_decode($this->templatesChildren['Retro board.whiteboard']->getContent(), true); + $this->assertSame([['type' => 'rectangle']], $content['elements']); + } + + // ------------------------------------------------------------------- + // listOrgCanvasTemplates / deleteOrgCanvasTemplate + // ------------------------------------------------------------------- + + public function testListOrgTemplatesSortedWithElementCounts(): void { + $this->orgCanvasTemplatesDirExists = true; + $this->orgCanvasTemplates['zebra.whiteboard'] = $this->mockWritableFile('zebra.whiteboard', json_encode(['elements' => [[], []]], JSON_THROW_ON_ERROR)); + $this->orgCanvasTemplates['Alpha.whiteboard'] = $this->mockWritableFile('Alpha.whiteboard', json_encode(['elements' => [[]]], JSON_THROW_ON_ERROR)); + $this->orgCanvasTemplates['broken.whiteboard'] = $this->mockWritableFile('broken.whiteboard', 'not json'); + + $templates = $this->service->listOrgCanvasTemplates(); + + $this->assertSame(['Alpha', 'broken', 'zebra'], array_column($templates, 'name')); + $this->assertSame([1, 0, 2], array_column($templates, 'elementCount')); + } + + public function testListOrgTemplatesEmptyWhenFolderMissing(): void { + $this->assertSame([], $this->service->listOrgCanvasTemplates()); + } + + public function testDeleteOrgTemplateRemovesFile(): void { + $this->orgCanvasTemplatesDirExists = true; + $file = $this->mockWritableFile('Kanban.whiteboard', '{}'); + $file->expects($this->once())->method('delete'); + $this->orgCanvasTemplates['Kanban.whiteboard'] = $file; + + $this->service->deleteOrgCanvasTemplate('Kanban'); + } + + public function testTemplateNameFromFileStripsExtension(): void { + $this->assertSame('Kanban', $this->service->canvasTemplateNameFromFile('Kanban.whiteboard')); + $this->assertSame('plain.txt', $this->service->canvasTemplateNameFromFile('plain.txt')); + } +} diff --git a/tests/Unit/Service/WhiteboardContentServiceTest.php b/tests/Unit/Service/WhiteboardContentServiceTest.php new file mode 100644 index 00000000..4ba05516 --- /dev/null +++ b/tests/Unit/Service/WhiteboardContentServiceTest.php @@ -0,0 +1,154 @@ +createMock(File::class); + $file->method('getId')->willReturn(123); + $file->method('getContent')->willReturn(json_encode([ + 'elements' => [ + ['id' => 'old-element', 'type' => 'rectangle'], + ], + 'files' => [], + 'libraryItems' => [ + [ + 'id' => 'library-item-1', + 'elements' => [ + ['id' => 'library-element-1', 'type' => 'ellipse'], + ], + ], + ], + 'scrollToContent' => true, + ], JSON_THROW_ON_ERROR)); + + $file->expects($this->once()) + ->method('putContent') + ->with($this->callback(static function (string $content): bool { + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + return $data['elements'][0]['id'] === 'new-element' + && $data['libraryItems'][0]['id'] === 'library-item-1' + && !isset($data['libraryMode']) + && !isset($data['librarySource']); + })); + + $service = new WhiteboardContentService($this->createMock(LoggerInterface::class)); + $service->updateContent($file, [ + 'data' => [ + 'elements' => [ + ['id' => 'new-element', 'type' => 'diamond'], + ], + 'files' => [], + 'scrollToContent' => true, + ], + ]); + } + + public function testValidLibraryRefIsPersisted(): void { + $file = $this->mockEmptyFile(); + $captured = $this->captureWrite($file); + + $this->service()->updateContent($file, [ + 'data' => [ + 'elements' => [['id' => 'el', 'type' => 'rectangle']], + 'files' => [], + 'libraryRef' => ['scope' => 'org', 'name' => 'Brand kit'], + ], + ]); + + // canonicalize() ksorts associative arrays, so compare order-insensitively. + $this->assertEquals(['scope' => 'org', 'name' => 'Brand kit'], $captured->data['libraryRef']); + } + + public function testInvalidLibraryRefNamesAreDropped(): void { + $file = $this->mockEmptyFile(); + $captured = $this->captureWrite($file); + + $this->service()->updateContent($file, [ + 'data' => [ + 'elements' => [['id' => 'el', 'type' => 'rectangle']], + 'files' => [], + 'libraryRef' => ['scope' => 'personal', 'name' => 'a/b'], + ], + ]); + + $this->assertArrayNotHasKey('libraryRef', $captured->data); + } + + public function testNullLibraryRefRemovesStoredRef(): void { + $file = $this->createMock(File::class); + $file->method('getId')->willReturn(123); + $file->method('getContent')->willReturn(json_encode([ + 'elements' => [['id' => 'el', 'type' => 'rectangle']], + 'files' => [], + 'libraryRef' => ['scope' => 'personal', 'name' => 'Kit'], + ], JSON_THROW_ON_ERROR)); + $captured = $this->captureWrite($file); + + $this->service()->updateContent($file, [ + 'data' => [ + 'elements' => [['id' => 'el2', 'type' => 'diamond']], + 'files' => [], + 'libraryRef' => null, + ], + ]); + + $this->assertArrayNotHasKey('libraryRef', $captured->data); + } + + public function testEmbeddedLibraryItemsLoseReadOnlyTag(): void { + $file = $this->mockEmptyFile(); + $captured = $this->captureWrite($file); + + $this->service()->updateContent($file, [ + 'data' => [ + 'elements' => [['id' => 'el', 'type' => 'rectangle']], + 'files' => [], + 'libraryItems' => [ + ['id' => 'item', 'elements' => [['type' => 'ellipse']], 'libraryName' => 'Kit', 'scope' => 'org'], + ], + ], + ]); + + $this->assertArrayNotHasKey('libraryName', $captured->data['libraryItems'][0]); + $this->assertArrayNotHasKey('scope', $captured->data['libraryItems'][0]); + } + + private function service(): WhiteboardContentService { + return new WhiteboardContentService($this->createMock(LoggerInterface::class)); + } + + /** @return File&MockObject */ + private function mockEmptyFile(): File { + $file = $this->createMock(File::class); + $file->method('getId')->willReturn(123); + $file->method('getContent')->willReturn(''); + return $file; + } + + /** + * Capture the JSON written via putContent as a decoded array on ->data. + * + * @param File&MockObject $file + */ + private function captureWrite(File $file): \stdClass { + $captured = new \stdClass(); + $captured->data = null; + $file->method('putContent')->willReturnCallback(static function (string $content) use ($captured): void { + $captured->data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + }); + return $captured; + } +} diff --git a/tests/Unit/Service/WhiteboardFolderServiceTest.php b/tests/Unit/Service/WhiteboardFolderServiceTest.php new file mode 100644 index 00000000..084a641f --- /dev/null +++ b/tests/Unit/Service/WhiteboardFolderServiceTest.php @@ -0,0 +1,78 @@ +service = new WhiteboardFolderService( + $this->createMock(ITemplateManager::class), + $this->createMock(IRootFolder::class), + $this->createMock(IConfig::class), + $this->createMock(LoggerInterface::class), + ); + } + + public function testNormalizeScopeAcceptsKnownScopes(): void { + $this->assertSame('personal', $this->service->normalizeScope('personal')); + $this->assertSame('org', $this->service->normalizeScope('org')); + } + + public function testNormalizeScopeRejectsUnknownScope(): void { + $this->expectException(InvalidArgumentException::class); + $this->service->normalizeScope('global'); + } + + public function testNormalizeNameTrimsWhitespace(): void { + $this->assertSame('My shapes', $this->service->normalizeName(' My shapes ')); + } + + #[DataProvider('invalidNameProvider')] + public function testNormalizeNameRejectsInvalidNames(string $name): void { + $this->expectException(InvalidArgumentException::class); + $this->service->normalizeName($name); + } + + public static function invalidNameProvider(): array { + return [ + 'empty' => [''], + 'whitespace only' => [' '], + 'dot' => ['.'], + 'dot dot' => ['..'], + 'slash' => ['a/b'], + 'backslash' => ['a\\b'], + 'control char' => ["a\x01b"], + 'too long' => [str_repeat('x', 251)], + 'windows reserved' => ['CON'], + 'windows reserved lowercase' => ['nul'], + 'windows reserved with extension' => ['com1.board'], + ]; + } + + public function testIsValidNameMatchesNormalizeName(): void { + $this->assertTrue(WhiteboardFolderService::isValidName('My shapes')); + $this->assertTrue(WhiteboardFolderService::isValidName('console')); + $this->assertFalse(WhiteboardFolderService::isValidName('CON')); + $this->assertFalse(WhiteboardFolderService::isValidName('')); + $this->assertFalse(WhiteboardFolderService::isValidName('a/b')); + $this->assertFalse(WhiteboardFolderService::isValidName(' padded ')); + $this->assertFalse(WhiteboardFolderService::isValidName(str_repeat('x', 251))); + } +} diff --git a/tests/Unit/Service/WhiteboardLibraryServiceTest.php b/tests/Unit/Service/WhiteboardLibraryServiceTest.php new file mode 100644 index 00000000..98159e0d --- /dev/null +++ b/tests/Unit/Service/WhiteboardLibraryServiceTest.php @@ -0,0 +1,387 @@ + node mock. */ + private array $templatesChildren = []; + /** Children of appdata whiteboard/, dir => [name => node mock]. */ + private array $appDataChildren = []; + + protected function setUp(): void { + parent::setUp(); + + $this->templateManager = $this->createMock(ITemplateManager::class); + $this->templateManager->method('hasTemplateDirectory')->willReturn(true); + $this->templateManager->method('getTemplatePath')->willReturn('Templates/'); + + $templatesFolder = $this->mockFolder($this->templatesChildren); + $userFolder = $this->createMock(Folder::class); + $userFolder->method('get')->with('Templates/')->willReturn($templatesFolder); + + // App data tree: appdata_test/whiteboard/. Lazily materialized + // folder mocks share their children arrays with $this->appDataChildren, + // so tests can seed files before a call and inspect writes after it. + $appDataDirs = []; + $makeDir = function (string $dir) use (&$appDataDirs): Folder { + if (!isset($appDataDirs[$dir])) { + if (!isset($this->appDataChildren[$dir])) { + $this->appDataChildren[$dir] = []; + } + $appDataDirs[$dir] = $this->mockFolder($this->appDataChildren[$dir]); + } + return $appDataDirs[$dir]; + }; + + $whiteboardFolder = $this->createMock(Folder::class); + $whiteboardFolder->method('nodeExists')->willReturnCallback(fn (string $dir): bool => isset($this->appDataChildren[$dir])); + $whiteboardFolder->method('get')->willReturnCallback($makeDir); + $whiteboardFolder->method('newFolder')->willReturnCallback($makeDir); + + $appDataRoot = $this->createMock(Folder::class); + $appDataRoot->method('nodeExists')->with('whiteboard')->willReturn(true); + $appDataRoot->method('get')->with('whiteboard')->willReturn($whiteboardFolder); + + $this->rootFolder = $this->createMock(IRootFolder::class); + $this->rootFolder->method('getUserFolder')->with(self::UID)->willReturn($userFolder); + $this->rootFolder->method('nodeExists')->willReturnCallback(static fn (string $n): bool => $n === 'appdata_test'); + $this->rootFolder->method('get')->willReturnCallback(function (string $path) use ($appDataRoot, $makeDir) { + if ($path === 'appdata_test') { + return $appDataRoot; + } + if (preg_match('#^appdata_test/whiteboard/(.+)$#', $path, $m) === 1 && isset($this->appDataChildren[$m[1]])) { + return $makeDir($m[1]); + } + throw new \OCP\Files\NotFoundException($path); + }); + + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueString')->with('instanceid', '')->willReturn('test'); + + $logger = $this->createMock(LoggerInterface::class); + $folders = new WhiteboardFolderService($this->templateManager, $this->rootFolder, $config, $logger, static fn (): bool => true); + $this->service = new WhiteboardLibraryService($folders, $config, $logger); + } + + // ------------------------------------------------------------------- + // helpers + // ------------------------------------------------------------------- + + /** + * Folder mock backed by a by-reference children array. newFile() and + * newFolder() mutate it, so created nodes are visible to later calls. + * + * @return Folder&MockObject + */ + private function mockFolder(array &$children): Folder { + $folder = $this->createMock(Folder::class); + $folder->method('nodeExists')->willReturnCallback(static function (string $name) use (&$children): bool { + return isset($children[$name]); + }); + $folder->method('get')->willReturnCallback(static function (string $name) use (&$children) { + if (!isset($children[$name])) { + throw new \OCP\Files\NotFoundException($name); + } + return $children[$name]; + }); + $folder->method('getDirectoryListing')->willReturnCallback(static function () use (&$children): array { + return array_values($children); + }); + $folder->method('newFile')->willReturnCallback(function (string $name) use (&$children) { + $children[$name] = $this->mockWritableFile($name); + return $children[$name]; + }); + $folder->method('newFolder')->willReturnCallback(function (string $name) use (&$children) { + $grandChildren = []; + $children[$name] = $this->mockFolder($grandChildren); + return $children[$name]; + }); + return $folder; + } + + /** @return File&MockObject */ + private function mockWritableFile(string $name, string $content = ''): File { + $state = new \stdClass(); + $state->content = $content; + $file = $this->createMock(File::class); + $file->method('getName')->willReturn($name); + $file->method('putContent')->willReturnCallback(static function ($newContent) use ($state): void { + $state->content = $newContent; + }); + $file->method('getContent')->willReturnCallback(static fn (): string => $state->content); + return $file; + } + + private function libraryJson(array $items): string { + return json_encode(['type' => 'excalidrawlib', 'version' => 2, 'libraryItems' => $items], JSON_THROW_ON_ERROR); + } + + private function pointerJson(string $scope, string $name): string { + return json_encode([ + 'elements' => [], + 'files' => [], + 'scrollToContent' => true, + 'libraryRef' => ['scope' => $scope, 'name' => $name], + ], JSON_THROW_ON_ERROR); + } + + // ------------------------------------------------------------------- + // saveLibrary + // ------------------------------------------------------------------- + + public function testSaveLibraryPersonalWritesItemsAndPointer(): void { + $result = $this->service->saveLibrary(self::UID, 'personal', ' My shapes ', [ + ['elements' => [['type' => 'rectangle']], 'libraryName' => 'stale-tag'], + ]); + + $this->assertSame(['name' => 'My shapes', 'scope' => 'personal'], $result); + + $libraryDir = $this->templatesChildren['.whiteboard-libraries']; + $itemsFile = $libraryDir->get('My shapes.excalidrawlib'); + $items = json_decode($itemsFile->getContent(), true)['libraryItems']; + $this->assertCount(1, $items); + $this->assertArrayNotHasKey('libraryName', $items[0]); + + $pointer = json_decode($this->templatesChildren['My shapes.whiteboard']->getContent(), true); + $this->assertSame(['scope' => 'personal', 'name' => 'My shapes'], $pointer['libraryRef']); + $this->assertSame([], $pointer['elements']); + } + + public function testSaveLibraryOrgWritesToAppData(): void { + $this->service->saveLibrary(self::UID, 'org', 'Brand kit', [ + ['elements' => [['type' => 'ellipse']]], + ]); + + $this->assertArrayHasKey('Brand kit.excalidrawlib', $this->appDataChildren['libraries']); + $pointer = json_decode($this->appDataChildren['library-pointers']['Brand kit.whiteboard']->getContent(), true); + $this->assertSame(['scope' => 'org', 'name' => 'Brand kit'], $pointer['libraryRef']); + } + + public function testSaveLibraryRejectsEmptyItems(): void { + $this->expectException(InvalidArgumentException::class); + $this->service->saveLibrary(self::UID, 'personal', 'Empty', [ + ['elements' => []], + 'not-an-item', + ]); + } + + public function testSaveLibraryRejectsInvalidScope(): void { + $this->expectException(InvalidArgumentException::class); + $this->service->saveLibrary(self::UID, 'global', 'Name', [['elements' => [['type' => 'rectangle']]]]); + } + + public function testSaveLibraryRejectsImageItems(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('image items'); + $this->service->saveLibrary(self::UID, 'personal', 'Pics', [ + ['elements' => [['type' => 'rectangle'], ['type' => 'image']]], + ]); + } + + public function testSaveLibraryConflictsWithCanvasTemplateOfSameName(): void { + $this->templatesChildren['Kit.whiteboard'] = $this->mockWritableFile( + 'Kit.whiteboard', + json_encode(['elements' => [['type' => 'rectangle']], 'files' => []], JSON_THROW_ON_ERROR) + ); + + try { + $this->service->saveLibrary(self::UID, 'personal', 'Kit', [['elements' => [['type' => 'ellipse']]]]); + $this->fail('Expected a conflict'); + } catch (InvalidArgumentException $e) { + $this->assertSame(\OCP\AppFramework\Http::STATUS_CONFLICT, $e->getCode()); + } + + // The conflict is detected before the items file is written — no orphan. + $this->assertArrayNotHasKey('.whiteboard-libraries', $this->templatesChildren); + // The canvas template was not touched. + $content = json_decode($this->templatesChildren['Kit.whiteboard']->getContent(), true); + $this->assertSame([['type' => 'rectangle']], $content['elements']); + } + + public function testSaveLibraryOverwritesExistingPointer(): void { + $this->templatesChildren['Kit.whiteboard'] = $this->mockWritableFile('Kit.whiteboard', $this->pointerJson('personal', 'Kit')); + + $result = $this->service->saveLibrary(self::UID, 'personal', 'Kit', [['elements' => [['type' => 'ellipse']]]]); + + $this->assertSame(['name' => 'Kit', 'scope' => 'personal'], $result); + $pointer = json_decode($this->templatesChildren['Kit.whiteboard']->getContent(), true); + $this->assertSame(['scope' => 'personal', 'name' => 'Kit'], $pointer['libraryRef']); + } + + // ------------------------------------------------------------------- + // listLibraries / resolveLibrary + // ------------------------------------------------------------------- + + public function testListLibrariesReturnsBothScopesSorted(): void { + $personalChildren = []; + $personalDir = $this->mockFolder($personalChildren); + $personalChildren['zebra.excalidrawlib'] = $this->mockWritableFile('zebra.excalidrawlib', $this->libraryJson([['elements' => [[]]]])); + $personalChildren['Alpha.excalidrawlib'] = $this->mockWritableFile('Alpha.excalidrawlib', $this->libraryJson([['elements' => [[]]], ['elements' => [[]]]])); + $personalChildren['broken.excalidrawlib'] = $this->mockWritableFile('broken.excalidrawlib', 'not json'); + $this->templatesChildren['.whiteboard-libraries'] = $personalDir; + + $this->appDataChildren['libraries'] = [ + 'Org kit.excalidrawlib' => $this->mockWritableFile('Org kit.excalidrawlib', $this->libraryJson([['elements' => [[]]]])), + ]; + + $result = $this->service->listLibraries(self::UID); + + $this->assertSame( + [['name' => 'Alpha', 'itemCount' => 2], ['name' => 'broken', 'itemCount' => 0], ['name' => 'zebra', 'itemCount' => 1]], + $result['personal'] + ); + $this->assertSame([['name' => 'Org kit', 'itemCount' => 1]], $result['org']); + } + + public function testResolveLibraryTagsItemsWithLibraryName(): void { + $personalChildren = []; + $personalDir = $this->mockFolder($personalChildren); + $personalChildren['Kit.excalidrawlib'] = $this->mockWritableFile('Kit.excalidrawlib', $this->libraryJson([ + ['id' => 'src1', 'elements' => [['type' => 'rectangle']], 'filename' => 'x', 'writable' => true], + ])); + $this->templatesChildren['.whiteboard-libraries'] = $personalDir; + + $items = $this->service->resolveLibrary(self::UID, 'personal', 'Kit'); + + $this->assertCount(1, $items); + $this->assertSame('Kit', $items[0]['libraryName']); + // Namespaced so resolved items never collide with their "My library" originals. + $this->assertSame('personal:Kit:src1', $items[0]['id']); + $this->assertArrayNotHasKey('filename', $items[0]); + $this->assertArrayNotHasKey('writable', $items[0]); + } + + public function testResolveLibraryReturnsEmptyWhenMissing(): void { + $this->assertSame([], $this->service->resolveLibrary(self::UID, 'org', 'Nope')); + $this->assertSame([], $this->service->resolveLibrary(self::UID, 'personal', 'Nope')); + } + + public function testListLibrariesEmptyWithoutAnyFolders(): void { + $this->assertSame(['personal' => [], 'org' => []], $this->service->listLibraries(self::UID)); + } + + public function testListLibrariesBlocksSharedSessionsFromOrg(): void { + $this->appDataChildren['libraries'] = [ + 'Org kit.excalidrawlib' => $this->mockWritableFile('Org kit.excalidrawlib', $this->libraryJson([['elements' => [[]]]])), + ]; + + $this->assertSame(['personal' => [], 'org' => []], $this->service->listLibraries('shared_token123')); + } + + public function testResolveLibraryBlocksSharedSessionsFromOrg(): void { + $this->appDataChildren['libraries'] = [ + 'Org kit.excalidrawlib' => $this->mockWritableFile('Org kit.excalidrawlib', $this->libraryJson([['elements' => [['type' => 'rectangle']]]])), + ]; + + $this->assertSame([], $this->service->resolveLibrary('shared_token123', 'org', 'Org kit')); + } + + // ------------------------------------------------------------------- + // deleteLibrary + // ------------------------------------------------------------------- + + public function testDeleteLibraryRemovesItemsAndPointer(): void { + $personalChildren = []; + $personalDir = $this->mockFolder($personalChildren); + $itemsFile = $this->mockWritableFile('Kit.excalidrawlib', $this->libraryJson([['elements' => [[]]]])); + $itemsFile->expects($this->once())->method('delete'); + $personalChildren['Kit.excalidrawlib'] = $itemsFile; + $this->templatesChildren['.whiteboard-libraries'] = $personalDir; + + $pointer = $this->mockWritableFile('Kit.whiteboard', $this->pointerJson('personal', 'Kit')); + $pointer->expects($this->once())->method('delete'); + $this->templatesChildren['Kit.whiteboard'] = $pointer; + + $this->service->deleteLibrary(self::UID, 'personal', 'Kit'); + } + + public function testDeleteLibraryKeepsCanvasTemplateOfSameName(): void { + $canvasTemplate = $this->mockWritableFile( + 'Kit.whiteboard', + json_encode(['elements' => [['type' => 'rectangle']], 'files' => []], JSON_THROW_ON_ERROR) + ); + $canvasTemplate->expects($this->never())->method('delete'); + $this->templatesChildren['Kit.whiteboard'] = $canvasTemplate; + + $this->service->deleteLibrary(self::UID, 'personal', 'Kit'); + } + + // ------------------------------------------------------------------- + // parseLibraryFile + // ------------------------------------------------------------------- + + public function testParseLibraryFileReadsV2(): void { + $items = $this->service->parseLibraryFile($this->libraryJson([['elements' => [['type' => 'rectangle']]]])); + $this->assertCount(1, $items); + } + + public function testParseLibraryFileReadsV1(): void { + $content = json_encode(['type' => 'excalidrawlib', 'version' => 1, 'library' => [[['type' => 'rectangle']]]], JSON_THROW_ON_ERROR); + $items = $this->service->parseLibraryFile($content); + $this->assertCount(1, $items); + $this->assertSame([['type' => 'rectangle']], $items[0]['elements']); + } + + public function testParseLibraryFileRejectsInvalidJson(): void { + $this->expectException(InvalidArgumentException::class); + $this->service->parseLibraryFile('not json'); + } + + public function testParseLibraryFileRejectsEmptyLibrary(): void { + $this->expectException(InvalidArgumentException::class); + $this->service->parseLibraryFile($this->libraryJson([])); + } + + public function testParseLibraryFileRejectsImageItems(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('image items'); + $this->service->parseLibraryFile($this->libraryJson([ + ['elements' => [['type' => 'image']]], + ])); + } + + // ------------------------------------------------------------------- + // pointer helpers + // ------------------------------------------------------------------- + + public function testLibraryNameFromPointerStripsExtension(): void { + $this->assertSame('Kit', $this->service->libraryNameFromPointer('Kit.whiteboard')); + $this->assertSame('other.txt', $this->service->libraryNameFromPointer('other.txt')); + } + + public function testGetOrgLibraryPointerFilesListsOnlyWhiteboardFiles(): void { + $this->appDataChildren['library-pointers'] = [ + 'Kit.whiteboard' => $this->mockWritableFile('Kit.whiteboard'), + 'readme.txt' => $this->mockWritableFile('readme.txt'), + ]; + + $files = $this->service->getOrgLibraryPointerFiles(); + $this->assertCount(1, $files); + $this->assertSame('Kit.whiteboard', $files[0]->getName()); + } +} diff --git a/vite.config.ts b/vite.config.ts index 459a2af6..6ace971c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,6 +13,7 @@ const AppConfig = createAppConfig({ main: resolve(join('src', 'main.ts')), settings: resolve(join('src', 'admin.ts')), personal: resolve(join('src', 'personal.ts')), + files: resolve(join('src', 'files.ts')), }, { config: defineConfig({ resolve: {