From 246784c2d6c8b39eb059ea9ef8440681cf71ac45 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 | 8 + lib/AppInfo/Application.php | 8 + lib/Controller/SettingsController.php | 43 ++ lib/Controller/WhiteboardController.php | 33 +- .../FileCreatedFromTemplateListener.php | 189 +++++ lib/Service/WhiteboardContentService.php | 79 +- lib/Service/WhiteboardLibraryService.php | 695 ++++++++++++++++-- .../GlobalLibraryTemplateProvider.php | 74 ++ package.json | 2 +- src/App.tsx | 297 +++++++- src/components/AdminSettings.vue | 150 +++- src/hooks/useBoardDataManager.ts | 90 +++ src/hooks/useLibrary.ts | 311 ++++++-- src/styles/globals/_layout.scss | 102 +++ src/utils/sanitizeAppState.ts | 1 + tests/Unit/AppInfo/ApplicationTest.php | 9 +- .../FileCreatedFromTemplateListenerTest.php | 82 +++ vite.config.ts | 13 + 18 files changed, 2033 insertions(+), 153 deletions(-) create mode 100644 lib/Listener/FileCreatedFromTemplateListener.php create mode 100644 lib/Template/GlobalLibraryTemplateProvider.php create mode 100644 tests/Unit/Listener/FileCreatedFromTemplateListenerTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 3e8e0cb3..9dae8c34 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -23,6 +23,8 @@ ['name' => 'Whiteboard#getLib', 'url' => 'library', 'verb' => 'GET'], /** @see WhiteboardController::updateLib() */ ['name' => 'Whiteboard#updateLib', 'url' => 'library', 'verb' => 'PUT'], + /** @see WhiteboardController::saveLibTemplate() */ + ['name' => 'Whiteboard#saveLibTemplate', 'url' => 'library/template', 'verb' => 'POST'], /** @see WhiteboardController::update() */ ['name' => 'Whiteboard#update', 'url' => '{fileId}', 'verb' => 'PUT'], /** @see WhiteboardController::show() */ @@ -33,6 +35,12 @@ ['name' => 'Recording#upload', 'url' => 'recording/{fileId}/upload', 'verb' => 'POST'], /** @see SettingsController::update() */ ['name' => 'Settings#update', 'url' => 'settings', 'verb' => 'POST'], + /** @see SettingsController::listGlobalLibraryTemplates() */ + ['name' => 'Settings#listGlobalLibraryTemplates', 'url' => 'settings/global-library', 'verb' => 'GET'], + /** @see SettingsController::uploadGlobalLibraryTemplate() */ + ['name' => 'Settings#uploadGlobalLibraryTemplate', 'url' => 'settings/global-library', 'verb' => 'POST'], + /** @see SettingsController::deleteGlobalLibraryTemplate() */ + ['name' => 'Settings#deleteGlobalLibraryTemplate', 'url' => 'settings/global-library/{templateName}', 'verb' => 'DELETE'], /** @see SettingsController::updatePersonal() */ ['name' => 'Settings#updatePersonal', 'url' => 'settings/personal', 'verb' => 'POST'], ] diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 3ca22b1b..72823751 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -14,14 +14,17 @@ use OCA\Viewer\Event\LoadViewer; use OCA\Whiteboard\Listener\AddContentSecurityPolicyListener; use OCA\Whiteboard\Listener\BeforeTemplateRenderedListener; +use OCA\Whiteboard\Listener\FileCreatedFromTemplateListener; use OCA\Whiteboard\Listener\LoadTextEditorListener; use OCA\Whiteboard\Listener\LoadViewerListener; use OCA\Whiteboard\Listener\RegisterTemplateCreatorListener; use OCA\Whiteboard\Settings\SetupCheck; +use OCA\Whiteboard\Template\GlobalLibraryTemplateProvider; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Files\Template\FileCreatedFromTemplateEvent; use OCP\Files\Template\ITemplateManager; use OCP\Files\Template\RegisterTemplateCreatorEvent; use OCP\IL10N; @@ -47,7 +50,12 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(LoadViewer::class, LoadViewerListener::class); $context->registerEventListener(LoadViewer::class, LoadTextEditorListener::class); $context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateCreatorListener::class); + $context->registerEventListener(FileCreatedFromTemplateEvent::class, FileCreatedFromTemplateListener::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); + [$major] = Util::getVersion(); + if ($major >= 30) { + $context->registerTemplateProvider(GlobalLibraryTemplateProvider::class); + } $context->registerSetupCheck(SetupCheck::class); } diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 3b46cc88..94386b50 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -13,8 +13,10 @@ 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 +33,7 @@ public function __construct( private ConfigService $configService, private SetupCheck $setupCheck, private IUserSession $userSession, + private WhiteboardLibraryService $libraryService, ) { parent::__construct('whiteboard', $request); } @@ -91,4 +94,44 @@ public function updatePersonal(): DataResponse { return $this->exceptionService->handleException($e); } } + + public function listGlobalLibraryTemplates(): DataResponse { + try { + return new DataResponse($this->libraryService->getGlobalTemplateMetadata()); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); + } + } + + public function uploadGlobalLibraryTemplate(): DataResponse { + try { + $uploadedFile = $this->request->getUploadedFile('file'); + if (!is_array($uploadedFile) || !isset($uploadedFile['tmp_name'], $uploadedFile['name'])) { + throw new Exception('No library template uploaded', Http::STATUS_BAD_REQUEST); + } + if (($uploadedFile['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { + throw new Exception('Library template upload failed', Http::STATUS_BAD_REQUEST); + } + + $content = file_get_contents($uploadedFile['tmp_name']); + if ($content === false) { + throw new Exception('Failed to read uploaded library template', Http::STATUS_BAD_REQUEST); + } + + return new DataResponse([ + 'template' => $this->libraryService->saveGlobalTemplateFromUpload($uploadedFile['name'], $content), + ], Http::STATUS_CREATED); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); + } + } + + public function deleteGlobalLibraryTemplate(string $templateName): DataResponse { + try { + $this->libraryService->deleteGlobalTemplate($templateName); + return new DataResponse(['status' => 'success']); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); + } + } } diff --git a/lib/Controller/WhiteboardController.php b/lib/Controller/WhiteboardController.php index 9f720c09..bb7ee3c0 100644 --- a/lib/Controller/WhiteboardController.php +++ b/lib/Controller/WhiteboardController.php @@ -10,6 +10,7 @@ namespace OCA\Whiteboard\Controller; use Exception; +use InvalidArgumentException; use OCA\Whiteboard\Exception\InvalidUserException; use OCA\Whiteboard\Exception\UnauthorizedException; use OCA\Whiteboard\Service\Authentication\GetUserFromIdServiceFactory; @@ -110,8 +111,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->getTemplates($userId); return new DataResponse(['data' => $data]); } catch (Exception $e) { @@ -126,8 +127,11 @@ public function updateLib(): DataResponse { try { $jwt = $this->getJwtFromRequest(); $userId = $this->jwtService->getUserIdFromJWT($jwt); - $items = $this->request->getParam('items', []); - $this->libraryService->updateUserLib($userId, $items); + $templates = $this->request->getParam('templates'); + if (!is_array($templates)) { + throw new InvalidArgumentException('Library templates payload is required', 400); + } + $this->libraryService->updateUserTemplates($userId, $templates); return new DataResponse(['status' => 'success']); } catch (Exception $e) { @@ -135,6 +139,27 @@ public function updateLib(): DataResponse { } } + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + public function saveLibTemplate(): DataResponse { + try { + $jwt = $this->getJwtFromRequest(); + $userId = $this->jwtService->getUserIdFromJWT($jwt); + $templateName = $this->request->getParam('templateName', ''); + $items = $this->request->getParam('items', []); + if (!is_string($templateName) || !is_array($items)) { + throw new InvalidArgumentException('Invalid library template payload', 400); + } + + $template = $this->libraryService->saveUserTemplate($userId, $templateName, $items); + + return new DataResponse(['status' => 'success', 'template' => $template]); + } 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/FileCreatedFromTemplateListener.php b/lib/Listener/FileCreatedFromTemplateListener.php new file mode 100644 index 00000000..29aaf5b4 --- /dev/null +++ b/lib/Listener/FileCreatedFromTemplateListener.php @@ -0,0 +1,189 @@ + */ +/** + * @psalm-suppress MissingTemplateParam + */ +final class FileCreatedFromTemplateListener implements IEventListener { + private const LIB_EXTENSION = '.excalidrawlib'; + private const WHITEBOARD_EXTENSION = '.whiteboard'; + private const VOLATILE_ELEMENT_KEYS = [ + 'id' => true, + 'seed' => true, + 'version' => true, + 'versionNonce' => true, + 'updated' => true, + 'index' => true, + 'groupIds' => true, + 'frameId' => true, + 'boundElements' => true, + 'containerId' => true, + ]; + + /** + * @psalm-suppress PossiblyUnusedMethod + */ + public function __construct( + private LoggerInterface $logger, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof FileCreatedFromTemplateEvent)) { + return; + } + + $template = $event->getTemplate(); + $target = $event->getTarget(); + if (!($template instanceof File)) { + return; + } + + if (!$this->isLibraryTemplate($template) || !$this->isWhiteboardTarget($target)) { + return; + } + + $libraryItems = $this->parseLibraryItems($template); + if ($libraryItems === []) { + return; + } + + try { + $target->putContent(json_encode([ + 'elements' => [], + 'files' => [], + 'libraryItems' => $libraryItems, + 'scrollToContent' => true, + ], JSON_THROW_ON_ERROR)); + } catch (\Throwable $e) { + $this->logger->warning('Failed to normalize whiteboard created from library template', [ + 'app' => 'whiteboard', + 'template' => $template->getPath(), + 'target' => $target->getPath(), + 'exception' => $e, + ]); + } + } + + private function isLibraryTemplate(File $file): bool { + return str_ends_with(strtolower($file->getName()), self::LIB_EXTENSION); + } + + private function isWhiteboardTarget(File $file): bool { + return str_ends_with(strtolower($file->getName()), self::WHITEBOARD_EXTENSION); + } + + private function parseLibraryItems(File $file): array { + try { + $data = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable) { + return []; + } + + if (!is_array($data)) { + return []; + } + + if (isset($data['libraryItems']) && is_array($data['libraryItems'])) { + return $this->normalizeLibraryItems($data['libraryItems']); + } + + if (isset($data['library']) && is_array($data['library'])) { + $items = []; + foreach ($data['library'] as $elements) { + if (!is_array($elements) || count($elements) === 0) { + continue; + } + $items[] = [ + 'id' => $this->createLibraryItemId($elements), + 'elements' => array_values($elements), + 'status' => 'published', + ]; + } + return $this->normalizeLibraryItems($items); + } + + return []; + } + + private function normalizeLibraryItems(array $items): array { + $normalized = []; + $seen = []; + + foreach ($items as $item) { + if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements']) || count($item['elements']) === 0) { + continue; + } + + unset($item['templateName'], $item['scope'], $item['filename'], $item['basename']); + $item['elements'] = array_values($item['elements']); + $key = $this->createLibraryItemId($item['elements']); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + + $item['id'] = isset($item['id']) && is_string($item['id']) && $item['id'] !== '' + ? $item['id'] + : $key; + $item['created'] = isset($item['created']) && is_numeric($item['created']) + ? (int)$item['created'] + : $this->nowMs(); + $item['status'] = isset($item['status']) && is_string($item['status']) + ? $item['status'] + : 'unpublished'; + + $normalized[] = $item; + } + + return $normalized; + } + + private function createLibraryItemId(array $elements): string { + $canonicalElements = $this->canonicalizeLibraryValue($elements); + $encoded = json_encode($canonicalElements); + return substr(hash('sha256', $encoded !== false ? $encoded : serialize($canonicalElements)), 0, 20); + } + + private function canonicalizeLibraryValue(mixed $value): mixed { + if (!is_array($value)) { + return $value; + } + + if ($this->isListArray($value)) { + return array_map(fn ($item) => $this->canonicalizeLibraryValue($item), $value); + } + + ksort($value); + $normalized = []; + foreach ($value as $key => $item) { + if (is_string($key) && isset(self::VOLATILE_ELEMENT_KEYS[$key])) { + continue; + } + $normalized[$key] = $this->canonicalizeLibraryValue($item); + } + return $normalized; + } + + private function nowMs(): int { + return (int)floor((float)microtime(true) * 1000.0); + } + + private function isListArray(array $value): bool { + return $value === [] || array_keys($value) === range(0, count($value) - 1); + } +} diff --git a/lib/Service/WhiteboardContentService.php b/lib/Service/WhiteboardContentService.php index 5ba88d87..b89f8402 100644 --- a/lib/Service/WhiteboardContentService.php +++ b/lib/Service/WhiteboardContentService.php @@ -17,6 +17,19 @@ use Psr\Log\LoggerInterface; final class WhiteboardContentService { + private const VOLATILE_ELEMENT_KEYS = [ + 'id' => true, + 'seed' => true, + 'version' => true, + 'versionNonce' => true, + 'updated' => true, + 'index' => true, + 'groupIds' => true, + 'frameId' => true, + 'boundElements' => true, + 'containerId' => true, + ]; + public function __construct( private LoggerInterface $logger, ) { @@ -168,6 +181,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']); } @@ -199,6 +216,14 @@ private function isEffectivelyEmptyPayload(array $payload): bool { return false; } + $hasLibraryItems = array_key_exists('libraryItems', $payload) + && is_array($payload['libraryItems']) + && !empty($payload['libraryItems']); + + if ($hasLibraryItems) { + return false; + } + if (array_key_exists('scrollToContent', $payload) && $payload['scrollToContent'] !== true) { return false; } @@ -212,7 +237,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; } } @@ -244,6 +269,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) { @@ -274,6 +303,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']); @@ -331,6 +364,50 @@ private function sanitizeFiles(array $files): array { return $sanitized; } + private function sanitizeLibraryItems(array $items): array { + $sanitized = []; + $seen = []; + + foreach ($items as $item) { + if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements']) || count($item['elements']) === 0) { + continue; + } + + $item['elements'] = array_values($item['elements']); + $canonicalElements = $this->canonicalizeLibraryValue($item['elements']); + $encoded = json_encode($canonicalElements); + $key = hash('sha256', $encoded !== false ? $encoded : serialize($canonicalElements)); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + unset($item['templateName'], $item['scope'], $item['filename'], $item['basename']); + $sanitized[] = $item; + } + + return $sanitized; + } + + private function canonicalizeLibraryValue(mixed $value): mixed { + if (!is_array($value)) { + return $value; + } + + if ($this->isList($value)) { + return array_map(fn ($item) => $this->canonicalizeLibraryValue($item), $value); + } + + ksort($value); + $normalized = []; + foreach ($value as $key => $item) { + if (is_string($key) && isset(self::VOLATILE_ELEMENT_KEYS[$key])) { + continue; + } + $normalized[$key] = $this->canonicalizeLibraryValue($item); + } + return $normalized; + } + /** * @param array $appState * diff --git a/lib/Service/WhiteboardLibraryService.php b/lib/Service/WhiteboardLibraryService.php index de27f938..454ba5a6 100644 --- a/lib/Service/WhiteboardLibraryService.php +++ b/lib/Service/WhiteboardLibraryService.php @@ -9,16 +9,22 @@ namespace OCA\Whiteboard\Service; +use Exception; +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\IFilenameValidator; 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; /** * @psalm-suppress UndefinedDocblockClass @@ -26,50 +32,163 @@ * @psalm-suppress MissingDependency */ final class WhiteboardLibraryService { + private const LIB_EXTENSION = '.excalidrawlib'; + private const PERSONAL_TEMPLATE = 'personal'; + private const BOARD_TEMPLATE = 'board'; + private const DEFAULT_TEMPLATE_DIR = 'Templates/'; + private const GLOBAL_TEMPLATE_DIR = 'global-libraries'; + private const GLOBAL_SCOPE = 'global'; + private const USER_SCOPE = 'user'; + private const MAX_FILENAME_BYTES = 250; + private const VOLATILE_ELEMENT_KEYS = [ + 'id' => true, + 'seed' => true, + 'version' => true, + 'versionNonce' => true, + 'updated' => true, + 'index' => true, + 'groupIds' => true, + 'frameId' => true, + 'boundElements' => true, + 'containerId' => true, + ]; + public function __construct( private ITemplateManager $templateManager, private IRootFolder $rootFolder, + private IFilenameValidator $filenameValidator, + private IConfig $config, + private LoggerInterface $logger, ) { } + /** + * @throws NotPermittedException + * @throws GenericFileException + * @throws LockedException + */ + public function getTemplates(string $uid): array { + return [ + 'templates' => $this->listUserTemplates($uid)['templates'], + ]; + } + + /** + * @throws NotPermittedException + * @throws GenericFileException + * @throws LockedException + */ + public function getGlobalTemplateMetadata(): array { + return [ + 'templates' => array_map(static fn (array $template): array => [ + 'templateName' => $template['templateName'], + 'scope' => $template['scope'], + 'itemCount' => count($template['items']), + ], $this->listGlobalTemplates()['templates']), + ]; + } + + /** + * @return array + * + * @throws NotPermittedException + * @throws GenericFileException + * @throws LockedException + */ + public function getGlobalTemplateFiles(): array { + return $this->listGlobalTemplates()['files']; + } + + /** + * @throws NotPermittedException + * @throws GenericFileException + * @throws LockedException + * @throws NotFoundException + */ + public function getGlobalTemplateFile(string $templateName): File { + $normalizedName = $this->normalizeTemplateName($templateName); + $files = $this->getGlobalTemplateFiles(); + $file = $files[$this->toCaseKey($normalizedName)] ?? null; + if (!$file instanceof File) { + throw new NotFoundException('Organization library template not found'); + } + return $file; + } + /** * @throws NotPermittedException * @throws GenericFileException * @throws LockedException * @throws JsonException */ - public function getUserLib(): array { - // Get the .excalidrawlib files from the /Templates directory - $availableFileCreators = $this->templateManager->listTemplates(); - $templates = []; - $libs = []; + public function saveGlobalTemplateFromUpload(string $fileName, string $content): array { + if (!$this->isLibraryFileName($fileName)) { + throw new InvalidArgumentException('Upload an .excalidrawlib file', Http::STATUS_BAD_REQUEST); + } - foreach ($availableFileCreators as $fileCreator) { - if ($fileCreator['app'] !== Application::APP_ID) { - continue; - } - $templates = $fileCreator['templates']; - break; + $templateName = $this->normalizeTemplateName($fileName); + $this->assertGlobalTemplateNameAllowed($templateName); + $caseKey = $this->toCaseKey($templateName); + $current = $this->listGlobalTemplates(); + + if (isset($current['loadedFiles'][$caseKey])) { + throw new RuntimeException('A library template with this name already exists. Rename the file and upload it again.', Http::STATUS_CONFLICT); } - foreach ($templates as $template) { - $templateDetails = $template->jsonSerialize(); + $items = $this->parseLibraryContent($content); + if ($items === null) { + throw new InvalidArgumentException('This is not a valid Excalidraw library file.', Http::STATUS_BAD_REQUEST); + } + if ($items === []) { + throw new InvalidArgumentException('This library has no reusable items. Upload a library with at least one item.', Http::STATUS_BAD_REQUEST); + } - if (str_ends_with($templateDetails['basename'], '.excalidrawlib')) { - $fileId = $templateDetails['fileid']; - $file = $this->rootFolder->getFirstNodeById($fileId); + $this->writeLibraryTemplate($this->getGlobalTemplateFolder(), $templateName, $items); - if (!$file instanceof File) { - continue; - } + return [ + 'templateName' => $templateName, + 'scope' => self::GLOBAL_SCOPE, + 'itemCount' => count($items), + ]; + } - $lib = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR); - $lib['basename'] = $templateDetails['basename']; - $libs[] = $lib; - } + /** + * @throws NotPermittedException + * @throws GenericFileException + * @throws LockedException + * @throws NotFoundException + */ + public function deleteGlobalTemplate(string $templateName): void { + $normalizedName = $this->normalizeTemplateName($templateName); + $current = $this->listGlobalTemplates(); + $fileName = $current['loadedFiles'][$this->toCaseKey($normalizedName)] ?? null; + if (!is_string($fileName)) { + throw new NotFoundException('Organization library template not found'); + } + + $node = $this->getGlobalTemplateFolder()->get($fileName); + if (!$node instanceof File) { + throw new NotFoundException('Organization library template not found'); } + $node->delete(); + } - return $libs; + /** + * @throws NotPermittedException + * @throws NotFoundException + * @throws GenericFileException + * @throws LockedException + * @throws JsonException + */ + public function updateUserTemplates(string $uid, array $templates): void { + $templateFolder = $this->getUserTemplateFolder($uid); + $current = $this->listUserTemplates($uid); + $loadedFiles = $current['loadedFiles']; + $payload = $this->normalizePayloadTemplates($templates, $loadedFiles); + + foreach ($payload as $templateName => $items) { + $this->writeUserTemplate($templateFolder, $templateName, $items); + } } /** @@ -79,57 +198,513 @@ public function getUserLib(): array { * @throws LockedException * @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); + public function saveUserTemplate(string $uid, string $templateName, array $items): array { + $templateFolder = $this->getUserTemplateFolder($uid); + $normalizedName = $this->normalizeTemplateName($templateName); + $caseKey = $this->toCaseKey($normalizedName); + $current = $this->listUserTemplates($uid); + $userNames = $current['loadedFiles']; + $normalizedItems = $this->normalizeLibraryItems($items); + + if (isset($userNames[$caseKey])) { + throw new RuntimeException('Library template already exists', Http::STATUS_CONFLICT); + } + + if ($normalizedItems === []) { + throw new InvalidArgumentException('Library template must contain at least one item', Http::STATUS_BAD_REQUEST); + } + + $contentKey = $this->createLibraryTemplateContentKey($normalizedItems); + foreach ($current['templates'] as $template) { + if ($this->createLibraryTemplateContentKey($template['items']) === $contentKey) { + throw new RuntimeException('Library template with same items already exists', Http::STATUS_CONFLICT); + } + } + + $this->writeUserTemplate($templateFolder, $normalizedName, $normalizedItems); + + return [ + 'templateName' => $normalizedName, + 'scope' => self::USER_SCOPE, + 'items' => $normalizedItems, + ]; + } + + /** + * @return array{templates: array, loadedFiles: array} + * + * @throws NotPermittedException + * @throws GenericFileException + * @throws LockedException + */ + private function listUserTemplates(string $uid): array { + $templateFolder = $this->getUserTemplateFolder($uid); + $templates = []; + $loadedFiles = []; + + foreach ($templateFolder->getDirectoryListing() as $node) { + if (!$node instanceof File || !$this->isLibraryFileName($node->getName())) { + continue; + } + + $templateName = $this->stripLibraryExtension($node->getName()); + $loadedFiles[$this->toCaseKey($templateName)] = $node->getName(); + $items = $this->parseLibraryContent($node->getContent()); + if ($items === null) { + $this->logger->warning('Skipping malformed whiteboard library template', [ + 'uid' => $uid, + 'file' => $node->getName(), + ]); + continue; + } + + $templates[] = [ + 'templateName' => $templateName, + 'scope' => self::USER_SCOPE, + 'items' => $items, + ]; } - // Update the .excalidrawlib files in the Templates directory + usort($templates, function (array $left, array $right): int { + return $this->sortTemplates($left, $right); + }); + + return [ + 'templates' => $templates, + 'loadedFiles' => $loadedFiles, + ]; + } + + /** + * @return array{templates: array, loadedFiles: array, files: array} + * + * @throws NotPermittedException + * @throws GenericFileException + * @throws LockedException + */ + private function listGlobalTemplates(): array { + $templateFolder = $this->getGlobalTemplateFolder(); + $templates = []; + $loadedFiles = []; + $files = []; + + foreach ($templateFolder->getDirectoryListing() as $node) { + if (!$node instanceof File || !$this->isLibraryFileName($node->getName())) { + continue; + } + + $templateName = $this->stripLibraryExtension($node->getName()); + $caseKey = $this->toCaseKey($templateName); + $loadedFiles[$caseKey] = $node->getName(); + $items = $this->parseLibraryContent($node->getContent()); + if ($items === null) { + $this->logger->warning('Skipping malformed organization whiteboard library template', [ + 'file' => $node->getName(), + ]); + continue; + } + + $templates[] = [ + 'templateName' => $templateName, + 'scope' => self::GLOBAL_SCOPE, + 'items' => $items, + ]; + $files[$caseKey] = $node; + } + + usort($templates, function (array $left, array $right): int { + return $this->sortTemplates($left, $right); + }); + + return [ + 'templates' => $templates, + 'loadedFiles' => $loadedFiles, + 'files' => $files, + ]; + } + + /** + * @throws NotPermittedException + */ + private function getUserTemplateFolder(string $uid): Folder { $userFolder = $this->rootFolder->getUserFolder($uid); - $templatesPath = $this->templateManager->getTemplatePath(); - $templatesFolder = $userFolder->get($templatesPath); + $configuredPath = $this->normalizeTemplatePath( + $this->config->getUserValue($uid, 'core', 'templateDirectory', '') + ); + $templateFolder = $configuredPath !== null + ? $this->getExistingFolder($userFolder, $configuredPath) + : null; + + if (!$templateFolder instanceof Folder) { + $templatePath = $this->templateManager->initializeTemplateDirectory(self::DEFAULT_TEMPLATE_DIR, $uid, false); + $templateFolder = $this->ensureFolder($userFolder, $this->normalizeTemplatePath($templatePath) ?? self::DEFAULT_TEMPLATE_DIR); + } + + $this->migrateRootPersonalTemplate($userFolder, $templateFolder); + + return $templateFolder; + } + + private function getExistingFolder(Folder $userFolder, string $path): ?Folder { + try { + if (!$userFolder->nodeExists($path)) { + return null; + } + $node = $userFolder->get($path); + return $node instanceof Folder ? $node : null; + } catch (Exception) { + return null; + } + } + + /** + * @throws NotPermittedException + */ + private function getGlobalTemplateFolder(): Folder { + $instanceId = $this->config->getSystemValueString('instanceid', ''); + if ($instanceId === '') { + throw new RuntimeException('No instance id configured'); + } + + $appDataRoot = $this->ensureChildFolder($this->rootFolder, 'appdata_' . $instanceId); + $appFolder = $this->ensureChildFolder($appDataRoot, 'whiteboard'); + return $this->ensureChildFolder($appFolder, self::GLOBAL_TEMPLATE_DIR); + } + + /** + * @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; + } + + /** + * @throws NotPermittedException + */ + private function ensureFolder(Folder $userFolder, string $path): Folder { + $normalizedPath = $this->normalizeTemplatePath($path) ?? trim(self::DEFAULT_TEMPLATE_DIR, '/'); + if (!$userFolder->nodeExists($normalizedPath)) { + $userFolder->newFolder($normalizedPath); + } + $folder = $userFolder->get($normalizedPath); + if (!$folder instanceof Folder) { + throw new RuntimeException('Template path is not a folder'); + } + return $folder; + } + + private function migrateRootPersonalTemplate(Folder $userFolder, Folder $templateFolder): void { + $fileName = $this->toLibraryFileName(self::PERSONAL_TEMPLATE); + try { + if (!$userFolder->nodeExists($fileName)) { + return; + } + if ($templateFolder->nodeExists($fileName)) { + $this->logger->info('Leaving root personal whiteboard library in place because target exists'); + return; + } + + $source = $userFolder->get($fileName); + if (!$source instanceof File) { + return; + } + if ($this->parseLibraryContent($source->getContent()) === null) { + $this->logger->warning('Leaving malformed root personal whiteboard library in place'); + return; + } + + $targetPath = $templateFolder->getPath() . '/' . $fileName; + try { + $source->move($targetPath); + return; + } catch (Exception $e) { + $this->logger->warning('Failed to move root personal whiteboard library, trying copy/delete', [ + 'exception' => $e->getMessage(), + ]); + } - if (!$templatesFolder instanceof Folder) { - throw new NotFoundException('Templates folder not found for user: ' . $uid); + try { + $source->copy($targetPath); + $source->delete(); + } catch (Exception $e) { + $this->logger->warning('Failed to migrate root personal whiteboard library', [ + 'exception' => $e->getMessage(), + ]); + $this->deleteTargetCopyAfterFailedMigration($templateFolder, $fileName); + } + } catch (Exception $e) { + $this->logger->warning('Failed to inspect root personal whiteboard library', [ + 'exception' => $e->getMessage(), + ]); } + } - $files = [ - 'personal.excalidrawlib' => [ - 'type' => 'excalidrawlib', - 'version' => 2, - 'libraryItems' => [], - ], + private function deleteTargetCopyAfterFailedMigration(Folder $templateFolder, string $fileName): void { + try { + if ($templateFolder->nodeExists($fileName)) { + $node = $templateFolder->get($fileName); + if ($node instanceof File) { + $node->delete(); + } + } + } catch (Exception) { + } + } + + /** + * @throws JsonException + */ + private function writeUserTemplate(Folder $templateFolder, string $templateName, array $items): void { + $this->writeLibraryTemplate($templateFolder, $templateName, $items); + } + + /** + * @throws JsonException + */ + private function writeLibraryTemplate(Folder $templateFolder, string $templateName, array $items): void { + $fileName = $this->toLibraryFileName($templateName); + $encoded = $this->encodeLibraryFile($items); + $file = $templateFolder->nodeExists($fileName) + ? $templateFolder->get($fileName) + : $templateFolder->newFile($fileName); + + if (!$file instanceof File) { + throw new GenericFileException('Failed to create or get file: ' . $fileName); + } + + $file->putContent($encoded); + } + + /** + * @throws JsonException + */ + private function encodeLibraryFile(array $items): string { + $fileData = [ + 'type' => 'excalidrawlib', + 'version' => 2, + 'libraryItems' => $this->normalizeLibraryItems($items), ]; + $encoded = json_encode($fileData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + if ($this->parseLibraryContent($encoded) === null) { + throw new InvalidArgumentException('Generated library file is invalid', Http::STATUS_BAD_REQUEST); + } + return $encoded; + } + + private function parseLibraryContent(string $content): ?array { + try { + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + + if (!is_array($data)) { + return null; + } + + if (array_key_exists('libraryItems', $data)) { + return is_array($data['libraryItems']) ? $this->normalizeLibraryItems($data['libraryItems']) : null; + } + + if (array_key_exists('library', $data)) { + return is_array($data['library']) ? $this->normalizeLegacyLibraryItems($data['library']) : null; + } + + if ($this->isListArray($data)) { + return $this->normalizeLibraryItems($data); + } + + return null; + } + + private function normalizeLegacyLibraryItems(array $libraries): array { + $items = []; + foreach ($libraries as $elements) { + if (!is_array($elements) || count($elements) === 0) { + continue; + } + $items[] = [ + 'id' => $this->createLibraryItemId($elements), + 'created' => $this->nowMs(), + 'status' => 'published', + 'elements' => array_values($elements), + ]; + } + return $this->normalizeLibraryItems($items); + } + private function normalizeLibraryItems(array $items): array { + $normalized = []; + $seen = []; 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], - ]; - } + if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements']) || count($item['elements']) === 0) { + continue; } + unset($item['templateName'], $item['scope'], $item['filename'], $item['basename']); + $item['elements'] = array_values($item['elements']); + $contentKey = $this->createLibraryItemId($item['elements']); + if (isset($seen[$contentKey])) { + continue; + } + $seen[$contentKey] = true; + $item['id'] = isset($item['id']) && is_string($item['id']) && $item['id'] !== '' + ? $item['id'] + : $contentKey; + $item['created'] = isset($item['created']) && is_numeric($item['created']) + ? (int)$item['created'] + : $this->nowMs(); + $item['status'] = isset($item['status']) && is_string($item['status']) + ? $item['status'] + : 'unpublished'; + $normalized[] = $item; } + return $normalized; + } + + private function normalizePayloadTemplates(array $templates, array $loadedFiles): array { + $normalized = []; + $seen = []; - foreach ($files as $filename => $fileData) { - if ($templatesFolder->nodeExists($filename)) { - $file = $templatesFolder->get($filename); - } else { - $file = $templatesFolder->newFile($filename); + foreach ($templates as $template) { + if (!is_array($template)) { + throw new InvalidArgumentException('Invalid library template payload', Http::STATUS_BAD_REQUEST); + } + if (isset($template['scope']) && $template['scope'] !== 'user') { + throw new InvalidArgumentException('Only user library templates can be updated here', Http::STATUS_BAD_REQUEST); + } + if (!isset($template['templateName']) || !is_string($template['templateName'])) { + throw new InvalidArgumentException('Library templateName is required', Http::STATUS_BAD_REQUEST); } - if (!$file instanceof File) { - throw new GenericFileException('Failed to create or get file: ' . $filename); + $templateName = $this->normalizeTemplateName($template['templateName']); + $caseKey = $this->toCaseKey($templateName); + if ($caseKey !== self::PERSONAL_TEMPLATE) { + throw new InvalidArgumentException('Only the personal library template can be updated here', Http::STATUS_BAD_REQUEST); + } + if (isset($seen[$caseKey])) { + throw new RuntimeException('Duplicate library template name', Http::STATUS_CONFLICT); } + $seen[$caseKey] = true; + $normalizedName = isset($loadedFiles[$caseKey]) + ? $this->stripLibraryExtension($loadedFiles[$caseKey]) + : $templateName; + $templateItems = $template['items'] ?? []; + $normalized[$normalizedName] = $this->normalizeLibraryItems(is_array($templateItems) ? $templateItems : []); + } + + return $normalized; + } + + private function normalizeTemplateName(string $templateName): string { + $name = trim($templateName); + if ($this->isLibraryFileName($name)) { + $name = trim($this->stripLibraryExtension($name)); + } + + if ($name === '' || $name === '.' || $name === '..') { + throw new InvalidArgumentException('Invalid library template name', Http::STATUS_BAD_REQUEST); + } + if (str_contains($name, '/') || str_contains($name, '\\') || preg_match('/[\x00-\x1F\x7F]/', $name) === 1) { + throw new InvalidArgumentException('Invalid library template name', Http::STATUS_BAD_REQUEST); + } - $file->putContent(json_encode($fileData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + $fileName = $this->toLibraryFileName($name); + if (strlen($fileName) > self::MAX_FILENAME_BYTES) { + throw new InvalidArgumentException('Library template name is too long', Http::STATUS_BAD_REQUEST); } + + try { + $this->filenameValidator->validateFilename($fileName); + } catch (Exception $e) { + throw new InvalidArgumentException('Invalid library template name: ' . $e->getMessage(), Http::STATUS_BAD_REQUEST, $e); + } + + return $name; + } + + private function assertGlobalTemplateNameAllowed(string $templateName): void { + $caseKey = $this->toCaseKey($templateName); + if ($caseKey === self::PERSONAL_TEMPLATE || $caseKey === self::BOARD_TEMPLATE) { + throw new InvalidArgumentException('Reserved library template name', Http::STATUS_BAD_REQUEST); + } + } + + private function normalizeTemplatePath(string $path): ?string { + $normalized = trim($path, '/'); + return $normalized === '' ? null : $normalized; + } + + private function toLibraryFileName(string $templateName): string { + return $templateName . self::LIB_EXTENSION; + } + + private function isLibraryFileName(string $fileName): bool { + return str_ends_with(strtolower($fileName), self::LIB_EXTENSION); + } + + private function stripLibraryExtension(string $fileName): string { + return substr($fileName, 0, -strlen(self::LIB_EXTENSION)); + } + + private function toCaseKey(string $value): string { + return strtolower($value); + } + + private function createLibraryItemId(array $elements): string { + $canonicalElements = $this->canonicalizeLibraryValue($elements); + $encoded = json_encode($canonicalElements); + return substr(hash('sha256', $encoded !== false ? $encoded : serialize($canonicalElements)), 0, 20); + } + + private function createLibraryTemplateContentKey(array $items): string { + $itemKeys = []; + foreach ($items as $item) { + if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements'])) { + continue; + } + $itemKeys[] = $this->createLibraryItemId($item['elements']); + } + sort($itemKeys); + return hash('sha256', implode("\n", $itemKeys)); + } + + private function canonicalizeLibraryValue(mixed $value): mixed { + if (!is_array($value)) { + return $value; + } + + if ($this->isListArray($value)) { + return array_map(fn ($item) => $this->canonicalizeLibraryValue($item), $value); + } + + ksort($value); + $normalized = []; + foreach ($value as $key => $item) { + if (is_string($key) && isset(self::VOLATILE_ELEMENT_KEYS[$key])) { + continue; + } + $normalized[$key] = $this->canonicalizeLibraryValue($item); + } + return $normalized; + } + + private function nowMs(): int { + return (int)floor((float)microtime(true) * 1000.0); + } + + private function isListArray(array $value): bool { + return $value === [] || array_keys($value) === range(0, count($value) - 1); + } + + private function sortTemplates(array $left, array $right): int { + return strcasecmp($left['templateName'], $right['templateName']); } } diff --git a/lib/Template/GlobalLibraryTemplateProvider.php b/lib/Template/GlobalLibraryTemplateProvider.php new file mode 100644 index 00000000..d6d6d9eb --- /dev/null +++ b/lib/Template/GlobalLibraryTemplateProvider.php @@ -0,0 +1,74 @@ + new Template(self::class, substr($file->getName(), 0, -strlen('.excalidrawlib')), $file), + array_values($this->libraryService->getGlobalTemplateFiles()) + ); + } catch (Exception $e) { + $this->logger->warning('Failed to list organization whiteboard library templates', [ + 'exception' => $e, + ]); + return []; + } + } + + /** + * @throws NotFoundException + */ + #[\Override] + public function getCustomTemplate(string $template): File { + try { + return $this->libraryService->getGlobalTemplateFile($template); + } catch (NotFoundException $e) { + throw $e; + } catch (Exception $e) { + $this->logger->warning('Failed to load organization whiteboard library template', [ + 'template' => $template, + 'exception' => $e, + ]); + throw new NotFoundException('Organization library template not found'); + } + } +} diff --git a/package.json b/package.json index a096fccf..01c197b3 100644 --- a/package.json +++ b/package.json @@ -126,4 +126,4 @@ "node": "^24.0.0", "npm": "^11.3.0" } -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index daa742c9..b595c6d2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,13 +5,14 @@ /* 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 type { FormEvent } from 'react' import { getCurrentUser } from '@nextcloud/auth' -import { translate as t } from '@nextcloud/l10n' +import { translate as t, translatePlural as n } 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 '@nextcloud/excalidraw/index.css' +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' @@ -54,6 +55,7 @@ import { VotingSidebar } from './components/VotingSidebar' import { useVoting } from './hooks/useVoting' import { useContextMenuFilter } from './hooks/useContextMenuFilter' import { useDisableExternalLibraries } from './hooks/useDisableExternalLibraries' +import { showError, showSuccess } from '@nextcloud/dialogs' const Excalidraw = memo(ExcalidrawComponent) @@ -61,6 +63,14 @@ const MemoizedNetworkStatusIndicator = memo(NetworkStatusIndicator) const MemoizedAuthErrorNotification = memo(AuthErrorNotification) const MemoizedExcalidrawMenu = memo(ExcalidrawMenu) +type LoadLibraryForApi = (api: ExcalidrawImperativeAPI) => void +type LibraryTemplateDialogSource = 'library' | 'selection' +const LIBRARY_TEMPLATE_LOADED_STORAGE_KEY = 'whiteboard.libraryTemplateLoaded' + +function formatLibraryItemCount(count: number): string { + return n('whiteboard', '%n library item', '%n library items', count) +} + export interface WhiteboardAppProps { fileId: number fileName: string @@ -98,6 +108,18 @@ export default function App({ setExcalidrawAPI: state.setExcalidrawAPI, resetExcalidrawAPI: state.resetExcalidrawAPI, }))) + const excalidrawAPIRef = useRef(null) + const loadLibraryForApiRef = useRef(() => {}) + const handleExcalidrawAPI = useCallback((api: ExcalidrawImperativeAPI | null) => { + if (api) { + excalidrawAPIRef.current = api + setExcalidrawAPI(api) + loadLibraryForApiRef.current(api) + return + } + excalidrawAPIRef.current = null + resetExcalidrawAPI() + }, [resetExcalidrawAPI, setExcalidrawAPI]) const { setConfig, @@ -130,7 +152,21 @@ export default function App({ const { renderAssistant } = useAssistant() const { renderEmojiPicker } = useEmojiPicker() const { onChange: onChangeSync, onPointerUpdate } = useSync() - const { fetchLibraryItems, updateLibraryItems, isLibraryLoaded, setIsLibraryLoaded } = useLibrary() + const { + fetchLibraryItems, + mergeInitialLibraryItems, + updateLibraryItems, + saveLibraryTemplate, + isLibraryLoaded, + setIsLibraryLoaded, + } = useLibrary() + const [libraryTemplateDialogItems, setLibraryTemplateDialogItems] = useState(null) + const [libraryTemplateDialogSource, setLibraryTemplateDialogSource] = useState('library') + const [libraryTemplateName, setLibraryTemplateName] = useState('') + const [libraryTemplateError, setLibraryTemplateError] = useState(null) + const [isSavingLibraryTemplate, setIsSavingLibraryTemplate] = useState(false) + const libraryTemplateNameInputRef = useRef(null) + const notifiedLibraryTemplateFileIdRef = useRef(null) useCollaboration() const { isReadOnly, refreshReadOnlyState } = useReadOnlyState() @@ -285,10 +321,67 @@ export default function App({ }, [handleExternalRestore, normalizedFileId]) // Use the board data manager hook - const { saveOnUnmount, isLoading } = useBoardDataManager() + const { saveOnUnmount, isLoading, getInitialLibraryItems, getInitialLibraryItemsPresent } = useBoardDataManager() + + loadLibraryForApiRef.current = (api: ExcalidrawImperativeAPI) => { + if (isLoading) { + return + } + + window.name = fileName + const loadLibraryItems = async () => { + try { + const initialLibraryItems = getInitialLibraryItems() + const hasInitialLibraryItems = getInitialLibraryItemsPresent() && initialLibraryItems.length > 0 + const libraryItems = await fetchLibraryItems() + const mergedLibraryItems = mergeInitialLibraryItems( + libraryItems || [], + initialLibraryItems, + hasInitialLibraryItems, + ) + await api.updateLibrary({ + libraryItems: mergedLibraryItems, + merge: false, + }) + if (hasInitialLibraryItems && !isVersionPreview) { + const notificationKey = `${LIBRARY_TEMPLATE_LOADED_STORAGE_KEY}.${normalizedFileId}` + let alreadyNotified = notifiedLibraryTemplateFileIdRef.current === normalizedFileId + try { + alreadyNotified = alreadyNotified || window.localStorage.getItem(notificationKey) === '1' + } catch { + // Ignore blocked storage. The notification is best-effort UI polish. + } + if (!alreadyNotified) { + api.toggleSidebar({ name: 'default', tab: 'library', force: true }) + showSuccess(t('whiteboard', 'Library template loaded. {items} were added to the Library sidebar.', { + items: formatLibraryItemCount(initialLibraryItems.length), + })) + notifiedLibraryTemplateFileIdRef.current = normalizedFileId + try { + window.localStorage.setItem(notificationKey, '1') + } catch { + // Ignore blocked storage. The in-memory guard still prevents duplicate toasts this load. + } + } + } + setIsLibraryLoaded(true) + } catch (error) { + logger.error('[App] Error updating library items:', error) + } + } + loadLibraryItems() + } + + useEffect(() => { + if (!excalidrawAPI || isLoading || isLibraryLoaded) { + return + } + loadLibraryForApiRef.current(excalidrawAPI) + }, [excalidrawAPI, isLoading, isLibraryLoaded]) // Effect to handle fileId changes - cleanup previous board data useEffect(() => { + setIsLibraryLoaded(false) // Clear any existing Excalidraw data when fileId changes if (excalidrawAPI) { excalidrawAPI.resetScene() @@ -306,41 +399,18 @@ export default function App({ }, [normalizedFileId, excalidrawAPI, resetInitialDataPromise, saveOnUnmount]) useEffect(() => { - resetInitialDataPromise() - - // 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) - try { - const libraryItems = await fetchLibraryItems() - await api.updateLibrary({ - libraryItems: libraryItems || [], - }) - setIsLibraryLoaded(true) - } catch (error) { - logger.error('[App] Error updating library items:', error) - } - }, 1000) - - // On unmount: Clean up all stores to prevent stale state return () => { - // Save any pending changes before resetting stores saveOnUnmount() - - // Reset all stores resetStore() resetExcalidrawAPI() - - // Terminate the worker terminateWorker() } - }, [resetInitialDataPromise, resetStore, resetExcalidrawAPI, terminateWorker, saveOnUnmount]) + }, [ + resetStore, + resetExcalidrawAPI, + terminateWorker, + saveOnUnmount, + ]) const [activeCommentThreadId, setActiveCommentThreadId] = useState(null) const [commentSidebarDocked, setCommentSidebarDocked] = useState(false) @@ -397,11 +467,98 @@ export default function App({ return } try { - await updateLibraryItems(items) + await updateLibraryItems(items, normalizedFileId, getInitialLibraryItemsPresent()) } catch (error) { logger.error('[App] Error syncing library items:', error) } - }, [isLibraryLoaded]) + }, [getInitialLibraryItemsPresent, isLibraryLoaded, normalizedFileId, updateLibraryItems]) + + useEffect(() => { + if (!libraryTemplateDialogItems) { + return + } + requestAnimationFrame(() => libraryTemplateNameInputRef.current?.focus()) + }, [libraryTemplateDialogItems]) + + const onLibrarySaveAsTemplate = useCallback((items: LibraryItems, context?: { source?: LibraryTemplateDialogSource }) => { + if (items.length === 0) { + showError(t('whiteboard', 'Add items to your library before saving a library template')) + return + } + + setLibraryTemplateDialogSource(context?.source === 'selection' ? 'selection' : 'library') + setLibraryTemplateName('') + setLibraryTemplateError(null) + setLibraryTemplateDialogItems(items) + }, []) + + const closeLibraryTemplateDialog = useCallback(() => { + if (isSavingLibraryTemplate) { + return + } + setLibraryTemplateDialogItems(null) + setLibraryTemplateDialogSource('library') + setLibraryTemplateName('') + setLibraryTemplateError(null) + }, [isSavingLibraryTemplate]) + + const submitLibraryTemplateDialog = useCallback(async (event?: FormEvent) => { + event?.preventDefault() + if (!libraryTemplateDialogItems || isSavingLibraryTemplate) { + return + } + + const templateName = libraryTemplateName.trim() + if (!templateName) { + setLibraryTemplateError(t('whiteboard', 'Library template name is required')) + return + } + + setIsSavingLibraryTemplate(true) + setLibraryTemplateError(null) + try { + await saveLibraryTemplate(templateName, libraryTemplateDialogItems) + const libraryItems = await fetchLibraryItems() + await (excalidrawAPI ?? excalidrawAPIRef.current)?.updateLibrary({ + libraryItems: mergeInitialLibraryItems( + libraryItems || [], + getInitialLibraryItems(), + getInitialLibraryItemsPresent(), + ), + merge: false, + }) + setLibraryTemplateDialogItems(null) + setLibraryTemplateDialogSource('library') + setLibraryTemplateName('') + showSuccess(t('whiteboard', 'Saved "{name}" as a library template with {items}.', { + name: templateName, + items: formatLibraryItemCount(libraryTemplateDialogItems.length), + })) + } catch (error: any) { + if (error?.status === 409) { + const conflictMessage = t('whiteboard', 'A library template with this name or the same items already exists') + setLibraryTemplateError(conflictMessage) + showError(conflictMessage) + return + } + logger.error('[App] Error saving library template:', error) + const errorMessage = t('whiteboard', 'Could not save library template') + setLibraryTemplateError(errorMessage) + showError(errorMessage) + } finally { + setIsSavingLibraryTemplate(false) + } + }, [ + excalidrawAPI, + fetchLibraryItems, + getInitialLibraryItems, + getInitialLibraryItemsPresent, + isSavingLibraryTemplate, + libraryTemplateDialogItems, + libraryTemplateName, + mergeInitialLibraryItems, + saveLibraryTemplate, + ]) const libraryReturnUrl = encodeURIComponent(window.location.href) @@ -524,7 +681,8 @@ export default function App({ validateEmbeddable={() => true} renderEmbeddable={Embeddable} beforeElementCreated={beforeElementCreated} - excalidrawAPI={setExcalidrawAPI} + excalidrawAPI={handleExcalidrawAPI} + onExcalidrawAPI={handleExcalidrawAPI} initialData={initialDataPromise} generateIdForFile={generateIdForFile} onPointerUpdate={onPointerUpdate} @@ -539,6 +697,7 @@ export default function App({ }} onLinkOpen={onLinkOpen} onLibraryChange={onLibraryChange} + onLibrarySaveAsTemplate={onLibrarySaveAsTemplate} langCode={lang} libraryReturnUrl={libraryReturnUrl} > @@ -616,6 +775,68 @@ export default function App({ settings={creatorDisplaySettings} /> )} + {libraryTemplateDialogItems && ( +
+
{ + if (event.key === 'Escape') { + event.stopPropagation() + closeLibraryTemplateDialog() + } + }} + > +

+ {libraryTemplateDialogSource === 'selection' + ? t('whiteboard', 'Save selected items as library template') + : t('whiteboard', 'Save library as template')} +

+

+ {t('whiteboard', 'Creates a template for future whiteboards. The canvas is not included.')} +

+

+ {formatLibraryItemCount(libraryTemplateDialogItems.length)} +

+ + setLibraryTemplateName(event.target.value)} + /> + {libraryTemplateError && ( +

+ {libraryTemplateError} +

+ )} +
+ + +
+
+
+ )} ) diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 9828fc68..3404687a 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -49,6 +49,46 @@

+ +

+ {{ t('whiteboard', 'Upload .excalidrawlib files to make reusable library items available when users create a new whiteboard. New boards start with an empty canvas and copied library items. Changes affect only future whiteboards.') }} +

+ + + {{ uploadingGlobalLibraryTemplate ? t('whiteboard', 'Uploading…') : t('whiteboard', 'Upload library template') }} + + + + + {{ t('whiteboard', 'Loading organization library templates…') }} + +

+ {{ t('whiteboard', 'No organization library templates yet. Upload an .excalidrawlib file to let users start new whiteboards with reusable library items.') }} +

+
    +
  • +
    + {{ template.templateName }} + {{ formatLibraryItemCount(template.itemCount) }} +
    + + {{ t('whiteboard', 'Delete') }} + +
  • +
+

diff --git a/src/hooks/useBoardDataManager.ts b/src/hooks/useBoardDataManager.ts index 699c11b6..52f8de25 100644 --- a/src/hooks/useBoardDataManager.ts +++ b/src/hooks/useBoardDataManager.ts @@ -18,10 +18,78 @@ import logger from '../utils/logger' import { computeElementVersionHash, mergeSceneElements } from '../utils/syncSceneData' import { sanitizeAppStateForSync } from '../utils/sanitizeAppState' +const VOLATILE_ELEMENT_KEYS = new Set([ + 'id', + 'seed', + 'version', + 'versionNonce', + 'updated', + 'index', + 'groupIds', + 'frameId', + 'boundElements', + 'containerId', +]) + +function canonicalizeLibraryValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeLibraryValue) + } + if (value && typeof value === 'object') { + const normalized: Record = {} + for (const key of Object.keys(value as Record).sort()) { + if (VOLATILE_ELEMENT_KEYS.has(key)) { + continue + } + normalized[key] = canonicalizeLibraryValue((value as Record)[key]) + } + return normalized + } + return value +} + +function sanitizeLibraryItems(items: unknown): any[] { + if (!Array.isArray(items)) { + return [] + } + + const sanitized: any[] = [] + const seen = new Set() + + for (const item of items) { + if (!item || typeof item !== 'object' || !Array.isArray((item as any).elements) || (item as any).elements.length === 0) { + continue + } + + const cleanItem = { ...(item as any) } + delete cleanItem.templateName + delete cleanItem.scope + delete cleanItem.filename + delete cleanItem.basename + cleanItem.elements = [...cleanItem.elements] + + let key = '' + try { + key = JSON.stringify(canonicalizeLibraryValue(cleanItem.elements)) + } catch { + key = cleanItem.id || '' + } + if (!key || seen.has(key)) { + continue + } + seen.add(key) + sanitized.push(cleanItem) + } + + return sanitized +} + export function useBoardDataManager() { const [isLoading, setIsLoading] = useState(true) const loadingTimeoutsRef = useRef>(new Set()) const currentFileIdRef = useRef(null) + const initialLibraryItemsRef = useRef([]) + const initialLibraryItemsPresentRef = useRef(false) const { fileId, @@ -82,6 +150,8 @@ export function useBoardDataManager() { }, []) const loadBoard = useCallback(async () => { + initialLibraryItemsRef.current = [] + initialLibraryItemsPresentRef.current = false if (isVersionPreview) { try { if (!versionSource) { @@ -139,6 +209,11 @@ export function useBoardDataManager() { ...sanitizedAppState, } + const libraryItemsPresent = Array.isArray(parsedContent.libraryItems) + const libraryItems = sanitizeLibraryItems(parsedContent.libraryItems) + initialLibraryItemsRef.current = libraryItems + initialLibraryItemsPresentRef.current = libraryItemsPresent + resolveInitialData({ elements: parsedContent.elements, files: parsedContent.files || {}, @@ -210,6 +285,7 @@ export function useBoardDataManager() { dataToUse = { elements: reconciledElements, files: mergedFiles, + libraryItems: sanitizeLibraryItems(serverData.libraryItems), appState: mergedAppState, scrollToContent: serverScrollToContent, } @@ -266,6 +342,11 @@ export function useBoardDataManager() { const sanitizedAppState = sanitizeAppStateForSync(dataToUse.appState) const finalAppState = { ...defaultSettings, ...sanitizedAppState } const files = dataToUse.files || {} + const libraryItemsPresent = Object.prototype.hasOwnProperty.call(dataToUse, 'libraryItems') + && Array.isArray(dataToUse.libraryItems) + const libraryItems = sanitizeLibraryItems(dataToUse.libraryItems) + initialLibraryItemsRef.current = libraryItems + initialLibraryItemsPresentRef.current = libraryItemsPresent // Force a small delay to ensure the component is ready to receive the data const timeout = setTimeout(() => { @@ -283,6 +364,8 @@ export function useBoardDataManager() { }, 50) loadingTimeoutsRef.current.add(timeout) } else { + initialLibraryItemsRef.current = [] + initialLibraryItemsPresentRef.current = false // No valid data from either source, use defaults // Force a small delay to ensure the component is ready to receive the data const timeout = setTimeout(() => { @@ -301,6 +384,8 @@ export function useBoardDataManager() { const timeout = setTimeout(() => { // Validate one more time before resolving if (currentFileIdRef.current === fileId) { + initialLibraryItemsRef.current = [] + initialLibraryItemsPresentRef.current = false resolveInitialData(initialDataState) setIsLoading(false) } @@ -396,9 +481,14 @@ export function useBoardDataManager() { } }, [cancelPendingTimeouts]) + const getInitialLibraryItems = useCallback(() => initialLibraryItemsRef.current, []) + const getInitialLibraryItemsPresent = useCallback(() => initialLibraryItemsPresentRef.current, []) + return { isLoading, loadBoard, saveOnUnmount, + getInitialLibraryItems, + getInitialLibraryItemsPresent, } } diff --git a/src/hooks/useLibrary.ts b/src/hooks/useLibrary.ts index 7721c7ab..c3b97990 100644 --- a/src/hooks/useLibrary.ts +++ b/src/hooks/useLibrary.ts @@ -3,15 +3,126 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useJWTStore } from '../stores/useJwtStore' import { useShallow } from 'zustand/react/shallow' import { generateUrl } from '@nextcloud/router' -import type { LibraryItem, LibraryItems } from '@excalidraw/excalidraw/types/types' +import type { LibraryItem, LibraryItems } from '@nextcloud/excalidraw/dist/types/excalidraw/types' import logger from '../utils/logger' -type LibraryItemExtended = LibraryItem & { - filename?: string; +type LibraryTemplate = { + templateName: string + items: LibraryItem[] +} + +type LibraryItemContext = { + templateName: string +} + +type LibrarySaveError = Error & { + status?: number +} + +const PERSONAL_TEMPLATE = 'personal' +const BOARD_TEMPLATE = '__board_template__' +const VOLATILE_ELEMENT_KEYS = new Set([ + 'id', + 'seed', + 'version', + 'versionNonce', + 'updated', + 'index', + 'groupIds', + 'frameId', + 'boundElements', + 'containerId', +]) + +function cleanLibraryItem(item: LibraryItem): LibraryItem { + const cleanItem = { ...item } as LibraryItem & Record + delete cleanItem.templateName + delete cleanItem.scope + delete cleanItem.filename + delete cleanItem.basename + return cleanItem +} + +function canonicalizeLibraryValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeLibraryValue) + } + if (value && typeof value === 'object') { + const normalized: Record = {} + for (const key of Object.keys(value as Record).sort()) { + if (VOLATILE_ELEMENT_KEYS.has(key)) { + continue + } + normalized[key] = canonicalizeLibraryValue((value as Record)[key]) + } + return normalized + } + return value +} + +function getLibraryItemContentKey(item: LibraryItem): string { + try { + return JSON.stringify(canonicalizeLibraryValue(item.elements ?? [])) + } catch { + return item.id || '' + } +} + +function dedupeLibraryItems(items: LibraryItems): LibraryItem[] { + const deduped: LibraryItem[] = [] + const seen = new Set() + + for (const item of items) { + const key = getLibraryItemContentKey(item) + if (!key || seen.has(key)) { + continue + } + seen.add(key) + deduped.push(cleanLibraryItem(item)) + } + + return deduped +} + +function normalizeTemplates(responseData: unknown): LibraryTemplate[] { + const templates = (responseData as { data?: { templates?: unknown } })?.data?.templates + if (!Array.isArray(templates)) { + return [] + } + + return templates + .filter((template): template is { templateName: string; scope?: string; items: LibraryItem[] } => { + const candidate = template as { templateName?: unknown; items?: unknown } + return typeof candidate.templateName === 'string' && Array.isArray(candidate.items) + }) + .map(template => ({ + templateName: template.templateName, + items: template.items, + })) +} + +function updateItemContextMap(templates: LibraryTemplate[], itemContexts: Map) { + for (const template of templates) { + for (const item of template.items) { + if (!item.id) { + continue + } + const existing = itemContexts.get(item.id) + if (existing?.templateName === PERSONAL_TEMPLATE && template.templateName !== PERSONAL_TEMPLATE) { + continue + } + if (existing && existing.templateName !== PERSONAL_TEMPLATE && template.templateName !== PERSONAL_TEMPLATE) { + continue + } + itemContexts.set(item.id, { + templateName: template.templateName, + }) + } + } } export function useLibrary() { @@ -22,6 +133,7 @@ export function useLibrary() { ) const [isLibraryLoaded, setIsLibraryLoaded] = useState(false) + const itemContextsRef = useRef>(new Map()) const fetchLibraryItems = useCallback(async (): Promise => { try { @@ -45,60 +157,128 @@ export function useLibrary() { } const data = await response.json() - const libraryItems: LibraryItems = [] + const templates = normalizeTemplates(data) + const personalTemplates = templates.filter(template => template.templateName.toLowerCase() === PERSONAL_TEMPLATE) + const personalItems = dedupeLibraryItems(personalTemplates.flatMap(template => template.items)) + const itemContexts = new Map() + updateItemContextMap([{ + templateName: PERSONAL_TEMPLATE, + items: personalItems, + }], itemContexts) + itemContextsRef.current = itemContexts + + return personalItems + } catch (error) { + logger.error('[Library] Error fetching library:', error) + return null + } + }, [getJWT]) - for (const file of data.data) { - if (!file.library && !file.libraryItems) { + const mergeInitialLibraryItems = useCallback((personalItems: LibraryItems, currentItems: LibraryItems, useBoardLibrary = false): LibraryItems => { + const merged: LibraryItem[] = [] + const seen = new Set() + const nextContexts = new Map() + + if (useBoardLibrary) { + for (const item of dedupeLibraryItems(currentItems)) { + const key = getLibraryItemContentKey(item) + if (!key || seen.has(key)) { continue } - - const date = new Date() - - // Handle for version 1 (legacy library files from https://excalidraw.com) - if (file.library) { - for (const elements of file.library) { - const item: LibraryItemExtended = { - id: '', - created: date.getTime(), - status: 'published', - elements, - filename: file.filename, - } - libraryItems.push(item) - } + seen.add(key) + merged.push(item) + if (item.id) { + nextContexts.set(item.id, { templateName: BOARD_TEMPLATE }) } + } + itemContextsRef.current = nextContexts + return merged + } - // Handle for version 2 - if (file.libraryItems) { - for (const item of file.libraryItems) { - if (!item.elements || item.elements.length === 0) { - continue - } - const libraryItem: LibraryItemExtended = { - id: item.id, - created: item.created || date.getTime(), - status: item.status || 'unpublished', - elements: item.elements, - filename: file.filename, - } - libraryItems.push(libraryItem) - } - } + for (const item of dedupeLibraryItems(personalItems)) { + const key = getLibraryItemContentKey(item) + seen.add(key) + merged.push(item) + if (item.id) { + nextContexts.set(item.id, { templateName: PERSONAL_TEMPLATE }) } - return libraryItems - } catch (error) { - logger.error('[Library] Error fetching library:', error) - return null } - }) - const updateLibraryItems = useCallback(async (items: LibraryItems): Promise => { + for (const item of dedupeLibraryItems(currentItems)) { + const key = getLibraryItemContentKey(item) + if (!key || seen.has(key)) { + continue + } + seen.add(key) + merged.push(item) + if (item.id) { + nextContexts.set(item.id, { templateName: BOARD_TEMPLATE }) + } + } + + itemContextsRef.current = nextContexts + return merged + }, []) + + const updateLibraryItems = useCallback(async (items: LibraryItems, boardFileId?: number | null, useBoardLibrary = false): Promise => { try { const jwt = await getJWT() if (!jwt) { logger.warn('[Library] No JWT found, cannot update library') return } + + if (useBoardLibrary && boardFileId) { + const boardItems = dedupeLibraryItems(items) + const url = generateUrl(`apps/whiteboard/${boardFileId}`) + const response = await globalThis.fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ + data: { + libraryItems: boardItems, + }, + }), + }) + + if (!response.ok) { + throw new Error(`Failed to update board library: ${response.statusText}`) + } + + const nextContexts = new Map() + updateItemContextMap([{ + templateName: BOARD_TEMPLATE, + items: boardItems, + }], nextContexts) + itemContextsRef.current = nextContexts + return + } + + const personalItems: LibraryItem[] = [] + const seen = new Set() + for (const item of items) { + const context = item.id ? itemContextsRef.current.get(item.id) : undefined + if (context && context.templateName !== PERSONAL_TEMPLATE) { + continue + } + const cleanItem = cleanLibraryItem(item) + const key = getLibraryItemContentKey(cleanItem) + if (!key || seen.has(key)) { + continue + } + seen.add(key) + personalItems.push(cleanItem) + } + + const templates = [{ + templateName: PERSONAL_TEMPLATE, + items: personalItems, + }] + const url = generateUrl('apps/whiteboard/library') const response = await globalThis.fetch(url, { method: 'PUT', @@ -107,20 +287,59 @@ export function useLibrary() { 'X-Requested-With': 'XMLHttpRequest', Authorization: `Bearer ${jwt}`, }, - body: JSON.stringify({ items }), + body: JSON.stringify({ templates }), }) if (!response.ok) { throw new Error(`Failed to update library: ${response.statusText}`) } + + const nextContexts = new Map( + Array.from(itemContextsRef.current.entries()).filter(([, context]) => context.templateName !== PERSONAL_TEMPLATE), + ) + updateItemContextMap(templates.map(template => ({ + templateName: template.templateName, + items: template.items, + })), nextContexts) + itemContextsRef.current = nextContexts } catch (error) { logger.error('[Library] Error updating library:', error) } - }) + }, [getJWT]) + + const saveLibraryTemplate = useCallback(async (templateName: string, items: LibraryItems): Promise => { + const jwt = await getJWT() + if (!jwt) { + logger.warn('[Library] No JWT found, cannot save library template') + return + } + + const url = generateUrl('apps/whiteboard/library/template') + const response = await globalThis.fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ + templateName, + items: items.map(cleanLibraryItem), + }), + }) + + if (!response.ok) { + const error = new Error(`Failed to save library template: ${response.statusText}`) as LibrarySaveError + error.status = response.status + throw error + } + }, [getJWT]) return { fetchLibraryItems, + mergeInitialLibraryItems, updateLibraryItems, + saveLibraryTemplate, isLibraryLoaded, setIsLibraryLoaded, } diff --git a/src/styles/globals/_layout.scss b/src/styles/globals/_layout.scss index 7e305384..2f166706 100644 --- a/src/styles/globals/_layout.scss +++ b/src/styles/globals/_layout.scss @@ -237,6 +237,108 @@ } } +.library-template-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); +} + +.library-template-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; + line-height: 1.3; + } + + p { + margin: 0; + } + + label { + font-weight: 600; + } + + input { + 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); + + &:focus-visible { + @include tokens.focus-ring(); + } + } +} + +.library-template-dialog__hint, +.library-template-dialog__count { + color: var(--color-text-maxcontrast); + line-height: 1.4; +} + +.library-template-dialog__error { + margin: 0; + color: var(--color-error); +} + +.library-template-dialog__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +.library-template-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; + + &:hover { + background: var(--color-background-dark); + } + + &:focus-visible { + @include tokens.focus-ring(); + } + + &:disabled { + cursor: default; + opacity: 0.6; + } +} + +.library-template-dialog__button--primary { + color: var(--color-primary-text); + background: var(--color-primary); + + &:hover { + background: var(--color-primary-element-hover); + } +} + .version-preview-banner { position: absolute; top: calc(var(--default-grid-baseline) * 2); diff --git a/src/utils/sanitizeAppState.ts b/src/utils/sanitizeAppState.ts index a05f8572..9e9252e8 100644 --- a/src/utils/sanitizeAppState.ts +++ b/src/utils/sanitizeAppState.ts @@ -12,6 +12,7 @@ const NON_TRANSFERRED_KEYS: Array = [ 'height', 'offsetTop', 'offsetLeft', + 'searchMatches', ] export function sanitizeAppStateForSync(state: Partial | AppState | null | undefined): Partial { diff --git a/tests/Unit/AppInfo/ApplicationTest.php b/tests/Unit/AppInfo/ApplicationTest.php index be02ba36..008b1f8f 100644 --- a/tests/Unit/AppInfo/ApplicationTest.php +++ b/tests/Unit/AppInfo/ApplicationTest.php @@ -10,10 +10,17 @@ namespace OCA\Whiteboard\AppInfo; +use OCA\Whiteboard\Template\GlobalLibraryTemplateProvider; +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); + $registrationContext->expects($this->once()) + ->method('registerTemplateProvider') + ->with(GlobalLibraryTemplateProvider::class); + $app = new Application(); $app->register($registrationContext); self::assertTrue(true); diff --git a/tests/Unit/Listener/FileCreatedFromTemplateListenerTest.php b/tests/Unit/Listener/FileCreatedFromTemplateListenerTest.php new file mode 100644 index 00000000..3865e48e --- /dev/null +++ b/tests/Unit/Listener/FileCreatedFromTemplateListenerTest.php @@ -0,0 +1,82 @@ +createMock(File::class); + $template->method('getName')->willReturn('Flowchart.excalidrawlib'); + $template->method('getPath')->willReturn('/appdata/whiteboard/global-libraries/Flowchart.excalidrawlib'); + $template->method('getContent')->willReturn(json_encode([ + 'type' => 'excalidrawlib', + 'version' => 2, + 'libraryItems' => [ + [ + 'id' => 'item-1', + 'status' => 'published', + 'elements' => [ + ['id' => 'element-1', 'type' => 'rectangle'], + ], + ], + ], + ], JSON_THROW_ON_ERROR)); + + $target = $this->createMock(File::class); + $target->method('getName')->willReturn('New whiteboard.whiteboard'); + $target->method('getPath')->willReturn('/admin/files/New whiteboard.whiteboard'); + $target->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'] === [] + && $data['files'] === [] + && $data['scrollToContent'] === true + && count($data['libraryItems']) === 1 + && $data['libraryItems'][0]['id'] === 'item-1'; + })); + + $listener = new FileCreatedFromTemplateListener($this->createMock(LoggerInterface::class)); + $listener->handle(new FileCreatedFromTemplateEvent($template, $target, [])); + } + + public function testAcceptsLegacyLibraryFormat(): void { + $template = $this->createMock(File::class); + $template->method('getName')->willReturn('Legacy.excalidrawlib'); + $template->method('getPath')->willReturn('/appdata/whiteboard/global-libraries/Legacy.excalidrawlib'); + $template->method('getContent')->willReturn(json_encode([ + 'type' => 'excalidrawlib', + 'library' => [ + [ + ['id' => 'element-1', 'type' => 'diamond'], + ], + ], + ], JSON_THROW_ON_ERROR)); + + $target = $this->createMock(File::class); + $target->method('getName')->willReturn('New whiteboard.whiteboard'); + $target->method('getPath')->willReturn('/admin/files/New whiteboard.whiteboard'); + $target->expects($this->once()) + ->method('putContent') + ->with($this->callback(static function (string $content): bool { + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + return count($data['libraryItems']) === 1 + && $data['libraryItems'][0]['status'] === 'published' + && $data['libraryItems'][0]['elements'][0]['type'] === 'diamond'; + })); + + $listener = new FileCreatedFromTemplateListener($this->createMock(LoggerInterface::class)); + $listener->handle(new FileCreatedFromTemplateEvent($template, $target, [])); + } +} diff --git a/vite.config.ts b/vite.config.ts index 459a2af6..a487dfde 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,7 +16,20 @@ const AppConfig = createAppConfig({ }, { config: defineConfig({ resolve: { + dedupe: ['react', 'react-dom'], alias: [ + { + find: 'vite-plugin-node-polyfills/shims/buffer', + replacement: resolve('node_modules/vite-plugin-node-polyfills/shims/buffer/dist/index.js'), + }, + { + find: 'vite-plugin-node-polyfills/shims/global', + replacement: resolve('node_modules/vite-plugin-node-polyfills/shims/global/dist/index.js'), + }, + { + find: 'vite-plugin-node-polyfills/shims/process', + replacement: resolve('node_modules/vite-plugin-node-polyfills/shims/process/dist/index.js'), + }, { find: /^@excalidraw\/element(.*)$/, replacement: '@nextcloud/excalidraw-element$1',