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..aeb9887b 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 preset uploaded', Http::STATUS_BAD_REQUEST); + } + if (($uploadedFile['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { + throw new Exception('Library preset upload failed', Http::STATUS_BAD_REQUEST); + } + + $content = file_get_contents($uploadedFile['tmp_name']); + if ($content === false) { + throw new Exception('Failed to read uploaded library preset', 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..4cc7eff0 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; @@ -20,6 +21,7 @@ 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; @@ -110,8 +112,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) { @@ -135,6 +137,28 @@ 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 preset payload', Http::STATUS_BAD_REQUEST); + } + + $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..0211566e --- /dev/null +++ b/lib/Listener/FileCreatedFromTemplateListener.php @@ -0,0 +1,81 @@ + */ +/** + * @psalm-suppress MissingTemplateParam + */ +final class FileCreatedFromTemplateListener implements IEventListener { + private const LIB_EXTENSION = '.excalidrawlib'; + private const WHITEBOARD_EXTENSION = '.whiteboard'; + private const GLOBAL_TEMPLATE_DIR = '/whiteboard/global-libraries/'; + + /** + * @psalm-suppress PossiblyUnusedMethod + */ + public function __construct( + private WhiteboardLibraryService $libraryService, + 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->isOrganizationLibraryTemplate($template) || !$this->isWhiteboardTarget($target)) { + return; + } + + $libraryItems = $this->libraryService->parseLibraryContent($template->getContent()); + if ($libraryItems === null) { + 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 preset', [ + 'app' => 'whiteboard', + 'template' => $template->getPath(), + 'target' => $target->getPath(), + 'exception' => $e, + ]); + } + } + + private function isOrganizationLibraryTemplate(File $file): bool { + return str_ends_with(strtolower($file->getName()), self::LIB_EXTENSION) + && str_contains($file->getPath(), self::GLOBAL_TEMPLATE_DIR); + } + + private function isWhiteboardTarget(File $file): bool { + return str_ends_with(strtolower($file->getName()), self::WHITEBOARD_EXTENSION); + } +} diff --git a/lib/Service/WhiteboardContentService.php b/lib/Service/WhiteboardContentService.php index 5ba88d87..a06bd576 100644 --- a/lib/Service/WhiteboardContentService.php +++ b/lib/Service/WhiteboardContentService.php @@ -168,6 +168,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']); } @@ -183,6 +187,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']); @@ -212,7 +220,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 +252,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 +286,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 +347,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['templateName'], $item['scope'], $item['filename'], $item['basename']); + $item['elements'] = array_values($item['elements']); + $sanitized[] = $item; + } + + return $sanitized; + } + /** * @param array $appState * diff --git a/lib/Service/WhiteboardLibraryService.php b/lib/Service/WhiteboardLibraryService.php index de27f938..d22dde03 100644 --- a/lib/Service/WhiteboardLibraryService.php +++ b/lib/Service/WhiteboardLibraryService.php @@ -9,8 +9,9 @@ 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; @@ -18,7 +19,10 @@ 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,47 +30,56 @@ * @psalm-suppress MissingDependency */ final class WhiteboardLibraryService { + private const LIB_EXTENSION = '.excalidrawlib'; + private const GLOBAL_TEMPLATE_DISPLAY_PREFIX = 'Org library preset - '; + private const GLOBAL_TEMPLATE_ID_PREFIX = 'organization-preset:'; + private const GLOBAL_TEMPLATE_DIR = 'global-libraries'; + private const MAX_FILENAME_BYTES = 250; + public function __construct( private ITemplateManager $templateManager, private IRootFolder $rootFolder, + private IConfig $config, + private LoggerInterface $logger, ) { } /** * @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 = []; + public function getUserLib(string $uid): array { + if (str_starts_with($uid, 'shared_')) { + return []; + } + + $templateFolder = $this->getUserTemplateFolder($uid); $libs = []; - foreach ($availableFileCreators as $fileCreator) { - if ($fileCreator['app'] !== Application::APP_ID) { + foreach ($templateFolder->getDirectoryListing() as $node) { + if (!$node instanceof File || !$this->isLibraryFileName($node->getName()) || str_starts_with($node->getName(), self::GLOBAL_TEMPLATE_DISPLAY_PREFIX)) { continue; } - $templates = $fileCreator['templates']; - break; - } - - foreach ($templates as $template) { - $templateDetails = $template->jsonSerialize(); - - if (str_ends_with($templateDetails['basename'], '.excalidrawlib')) { - $fileId = $templateDetails['fileid']; - $file = $this->rootFolder->getFirstNodeById($fileId); - if (!$file instanceof File) { - continue; - } - - $lib = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR); - $lib['basename'] = $templateDetails['basename']; - $libs[] = $lib; + $libraryItems = $this->parseLibraryContent($node->getContent()); + if ($libraryItems === null) { + $this->logger->warning('Skipping malformed whiteboard library preset', [ + 'uid' => $uid, + 'file' => $node->getName(), + ]); + continue; } + + $libs[] = [ + 'type' => 'excalidrawlib', + 'version' => 2, + 'libraryItems' => $libraryItems, + 'basename' => $node->getName(), + 'filename' => $node->getName(), + ]; } return $libs; @@ -80,19 +93,11 @@ 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); - - if (!$templatesFolder instanceof Folder) { - throw new NotFoundException('Templates folder not found for user: ' . $uid); - } + $templatesFolder = $this->getUserTemplateFolder($uid); $files = [ 'personal.excalidrawlib' => [ @@ -132,4 +137,461 @@ public function updateUserLib(string $uid, array $items): void { $file->putContent(json_encode($fileData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); } } + + /** + * @throws NotPermittedException + * @throws NotFoundException + * @throws GenericFileException + * @throws LockedException + * @throws JsonException + */ + public function saveUserTemplate(string $uid, string $templateName, array $items): array { + if (str_starts_with($uid, 'shared_')) { + throw new InvalidArgumentException('Shared users cannot save library presets', Http::STATUS_BAD_REQUEST); + } + + $templateFolder = $this->getUserTemplateFolder($uid); + $normalizedName = $this->normalizeTemplateName($templateName); + $currentFiles = $this->listUserLibraryFiles($templateFolder); + $caseKey = $this->toCaseKey($normalizedName); + + if (isset($currentFiles[$caseKey])) { + throw new RuntimeException('Library preset already exists', Http::STATUS_CONFLICT); + } + + $normalizedItems = $this->normalizeLibraryItems($items); + if ($normalizedItems === []) { + throw new InvalidArgumentException('Library preset must contain at least one item', Http::STATUS_BAD_REQUEST); + } + if ($this->containsImageElement($normalizedItems)) { + throw new InvalidArgumentException('This library contains image items that cannot be imported by Whiteboard yet.', Http::STATUS_BAD_REQUEST); + } + + $this->writeUserTemplate($templateFolder, $normalizedName, $normalizedItems); + + return [ + 'templateName' => $normalizedName, + 'itemCount' => count($normalizedItems), + ]; + } + + /** + * @throws NotPermittedException + * @throws GenericFileException + * @throws LockedException + */ + public function getGlobalTemplateMetadata(): array { + return [ + 'templates' => array_map(static fn (array $template): array => [ + 'templateName' => $template['templateName'], + '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 $templateId): File { + $normalizedName = $this->normalizeGlobalTemplateId($templateId); + $files = $this->getGlobalTemplateFiles(); + $file = $files[$this->toCaseKey($normalizedName)] ?? null; + if (!$file instanceof File) { + throw new NotFoundException('Organization library preset not found'); + } + return $file; + } + + public function getGlobalTemplateId(string $templateName): string { + return self::GLOBAL_TEMPLATE_ID_PREFIX . $this->toCaseKey($templateName); + } + + public function getGlobalTemplateNameFromFileName(string $fileName): string { + $name = $this->stripLibraryExtension($fileName); + if (str_starts_with($name, self::GLOBAL_TEMPLATE_DISPLAY_PREFIX)) { + $name = substr($name, strlen(self::GLOBAL_TEMPLATE_DISPLAY_PREFIX)); + } + return $name; + } + + /** + * @throws NotPermittedException + * @throws GenericFileException + * @throws LockedException + * @throws JsonException + */ + public function saveGlobalTemplateFromUpload(string $fileName, string $content): array { + if (!$this->isLibraryFileName($fileName)) { + throw new InvalidArgumentException('Upload an .excalidrawlib file', Http::STATUS_BAD_REQUEST); + } + + $templateName = $this->normalizeTemplateName($fileName); + $caseKey = $this->toCaseKey($templateName); + $current = $this->listGlobalTemplates(); + + if (isset($current['loadedFiles'][$caseKey])) { + throw new RuntimeException('A library preset with this name already exists. Rename the file and upload it again.', Http::STATUS_CONFLICT); + } + + $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 ($this->containsImageElement($items)) { + throw new InvalidArgumentException('This library contains image items that cannot be imported by Whiteboard yet.', Http::STATUS_BAD_REQUEST); + } + + $this->writeGlobalTemplate($this->getGlobalTemplateFolder(), $templateName, $items); + + return [ + 'templateName' => $templateName, + 'itemCount' => count($items), + ]; + } + + /** + * @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 preset not found'); + } + + $node = $this->getGlobalTemplateFolder()->get($fileName); + if (!$node instanceof File) { + throw new NotFoundException('Organization library preset not found'); + } + $node->delete(); + } + + /** + * @return array>|null + */ + public 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; + } + + /** + * @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->getGlobalTemplateNameFromFileName($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 preset', [ + 'file' => $node->getName(), + ]); + continue; + } + + $templates[] = [ + 'templateName' => $templateName, + 'items' => $items, + ]; + $files[$caseKey] = $node; + } + + usort($templates, static fn (array $left, array $right): int => strcasecmp($left['templateName'], $right['templateName'])); + + return [ + 'templates' => $templates, + 'loadedFiles' => $loadedFiles, + 'files' => $files, + ]; + } + + /** + * @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 + * @throws NotFoundException + */ + private function getUserTemplateFolder(string $uid): Folder { + if (!$this->templateManager->hasTemplateDirectory()) { + $this->templateManager->initializeTemplateDirectory(null, $uid, false); + } + + $userFolder = $this->rootFolder->getUserFolder($uid); + $templatesPath = $this->templateManager->getTemplatePath(); + $templatesFolder = $userFolder->get($templatesPath); + + if (!$templatesFolder instanceof Folder) { + throw new NotFoundException('Templates folder not found for user: ' . $uid); + } + + return $templatesFolder; + } + + /** + * @return array + * + * @throws NotPermittedException + * @throws GenericFileException + * @throws LockedException + */ + private function listUserLibraryFiles(Folder $templateFolder): array { + $loadedFiles = []; + + foreach ($templateFolder->getDirectoryListing() as $node) { + if (!$node instanceof File || !$this->isLibraryFileName($node->getName())) { + continue; + } + + $templateName = $this->stripLibraryExtension($node->getName()); + if (str_starts_with($templateName, self::GLOBAL_TEMPLATE_DISPLAY_PREFIX)) { + continue; + } + $loadedFiles[$this->toCaseKey($templateName)] = $node->getName(); + } + + return $loadedFiles; + } + + /** + * @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 JsonException + */ + private function writeGlobalTemplate(Folder $templateFolder, string $templateName, array $items): void { + $fileName = $this->toGlobalLibraryFileName($templateName); + $this->writeTemplateFile($templateFolder, $fileName, $items); + } + + /** + * @throws JsonException + */ + private function writeUserTemplate(Folder $templateFolder, string $templateName, array $items): void { + $fileName = $this->toLibraryFileName($templateName); + $this->writeTemplateFile($templateFolder, $fileName, $items); + } + + /** + * @throws JsonException + */ + private function writeTemplateFile(Folder $templateFolder, string $fileName, array $items): void { + $encoded = json_encode([ + 'type' => 'excalidrawlib', + 'version' => 2, + 'libraryItems' => $this->normalizeLibraryItems($items), + ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + $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); + } + + 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 = []; + 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']); + $item['id'] = isset($item['id']) && is_string($item['id']) && $item['id'] !== '' + ? $item['id'] + : $this->createLibraryItemId($item['elements']); + $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 normalizeTemplateName(string $templateName): string { + $name = trim($templateName); + if ($this->isLibraryFileName($name)) { + $name = trim($this->stripLibraryExtension($name)); + } + if (str_starts_with($name, self::GLOBAL_TEMPLATE_DISPLAY_PREFIX)) { + $name = trim(substr($name, strlen(self::GLOBAL_TEMPLATE_DISPLAY_PREFIX))); + } + + if ($name === '' || $name === '.' || $name === '..') { + throw new InvalidArgumentException('Invalid library preset name', Http::STATUS_BAD_REQUEST); + } + if (str_contains($name, '/') || str_contains($name, '\\') || preg_match('/[\x00-\x1F\x7F]/', $name) === 1) { + throw new InvalidArgumentException('Invalid library preset name', Http::STATUS_BAD_REQUEST); + } + if (strlen($this->toGlobalLibraryFileName($name)) > self::MAX_FILENAME_BYTES) { + throw new InvalidArgumentException('Library preset name is too long', Http::STATUS_BAD_REQUEST); + } + + return $name; + } + + private function toLibraryFileName(string $templateName): string { + return $templateName . self::LIB_EXTENSION; + } + + private function toGlobalLibraryFileName(string $templateName): string { + return self::GLOBAL_TEMPLATE_DISPLAY_PREFIX . $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 normalizeGlobalTemplateId(string $templateId): string { + if (!str_starts_with($templateId, self::GLOBAL_TEMPLATE_ID_PREFIX)) { + throw new NotFoundException('Organization library preset not found'); + } + $caseKey = substr($templateId, strlen(self::GLOBAL_TEMPLATE_ID_PREFIX)); + if ($caseKey === '' || str_contains($caseKey, '/') || str_contains($caseKey, '\\')) { + throw new NotFoundException('Organization library preset not found'); + } + $files = $this->listGlobalTemplates()['loadedFiles']; + $fileName = $files[$caseKey] ?? null; + if (!is_string($fileName)) { + throw new NotFoundException('Organization library preset not found'); + } + return $this->getGlobalTemplateNameFromFileName($fileName); + } + + 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 createLibraryItemId(array $elements): string { + $encoded = json_encode($elements); + return substr(hash('sha256', $encoded !== false ? $encoded : serialize($elements)), 0, 20); + } + + 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/Settings/Admin.php b/lib/Settings/Admin.php index c386b7d4..79142624 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,8 @@ 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()); + [$major] = Util::getVersion(); + $this->initialState->provideInitialState('globalLibraryTemplatesSupported', $major >= 30); $response = new TemplateResponse( 'whiteboard', 'admin', diff --git a/lib/Template/GlobalLibraryTemplateProvider.php b/lib/Template/GlobalLibraryTemplateProvider.php new file mode 100644 index 00000000..b6a5801b --- /dev/null +++ b/lib/Template/GlobalLibraryTemplateProvider.php @@ -0,0 +1,78 @@ + new Template( + self::class, + $this->libraryService->getGlobalTemplateId($this->libraryService->getGlobalTemplateNameFromFileName($file->getName())), + $file + ), + array_values($this->libraryService->getGlobalTemplateFiles()) + ); + } catch (Exception $e) { + $this->logger->warning('Failed to list organization whiteboard library presets', [ + '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 preset', [ + 'template' => $template, + 'exception' => $e, + ]); + throw new NotFoundException('Organization library preset not found'); + } + } +} diff --git a/src/App.tsx b/src/App.tsx index daa742c9..d802ee1f 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 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,12 @@ const MemoizedNetworkStatusIndicator = memo(NetworkStatusIndicator) const MemoizedAuthErrorNotification = memo(AuthErrorNotification) const MemoizedExcalidrawMenu = memo(ExcalidrawMenu) +type LibraryTemplateDialogSource = 'library' | 'selection' + +function formatLibraryItemCount(count: number): string { + return n('whiteboard', '%n library item', '%n library items', count) +} + export interface WhiteboardAppProps { fileId: number fileName: string @@ -130,7 +138,14 @@ export default function App({ const { renderAssistant } = useAssistant() const { renderEmojiPicker } = useEmojiPicker() const { onChange: onChangeSync, onPointerUpdate } = useSync() - const { fetchLibraryItems, updateLibraryItems, isLibraryLoaded, setIsLibraryLoaded } = useLibrary() + const { fetchLibraryItems, updateLibraryItems, saveLibraryTemplate, isLibraryLoaded, setIsLibraryLoaded } = useLibrary() + const initialLibraryItemIdsRef = useRef>(new Set()) + 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) useCollaboration() const { isReadOnly, refreshReadOnlyState } = useReadOnlyState() @@ -165,6 +180,12 @@ export default function App({ useContextMenuFilter(excalidrawAPI) useDisableExternalLibraries() + useEffect(() => { + if (libraryTemplateDialogItems) { + libraryTemplateNameInputRef.current?.focus() + } + }, [libraryTemplateDialogItems]) + useEffect(() => { const handleVideoError = (e: Event) => { const target = e.target as HTMLElement @@ -285,10 +306,18 @@ export default function App({ }, [handleExternalRestore, normalizedFileId]) // Use the board data manager hook - const { saveOnUnmount, isLoading } = useBoardDataManager() + const { + saveOnUnmount, + isLoading, + getInitialLibraryItems, + getInitialLibraryItemsPresent, + } = useBoardDataManager() // Effect to handle fileId changes - cleanup previous board data useEffect(() => { + setIsLibraryLoaded(false) + initialLibraryItemIdsRef.current = new Set() + // Clear any existing Excalidraw data when fileId changes if (excalidrawAPI) { excalidrawAPI.resetScene() @@ -303,13 +332,16 @@ export default function App({ saveOnUnmount() } } - }, [normalizedFileId, excalidrawAPI, resetInitialDataPromise, saveOnUnmount]) + }, [normalizedFileId, excalidrawAPI, resetInitialDataPromise, saveOnUnmount, setIsLibraryLoaded]) useEffect(() => { - resetInitialDataPromise() + if (isLoading) { + return + } // Fetch library items from the API window.name = fileName + setIsLibraryLoaded(false) const fetchLibInterval = setInterval(async () => { const api = useExcalidrawStore.getState().excalidrawAPI if (!api) { @@ -319,8 +351,26 @@ export default function App({ clearInterval(fetchLibInterval) try { const libraryItems = await fetchLibraryItems() + const embeddedLibraryItems = getInitialLibraryItems() + const embeddedLibraryItemIds = new Set( + getInitialLibraryItemsPresent() + ? embeddedLibraryItems.map(item => item?.id).filter((id): id is string => typeof id === 'string' && id !== '') + : [], + ) + const mergedLibraryItems = [...(libraryItems || [])] + const seenItemIds = new Set(mergedLibraryItems.map(item => item.id).filter(Boolean)) + for (const item of embeddedLibraryItems) { + if (item.id && seenItemIds.has(item.id)) { + continue + } + mergedLibraryItems.push(item) + if (item.id) { + seenItemIds.add(item.id) + } + } + initialLibraryItemIdsRef.current = embeddedLibraryItemIds await api.updateLibrary({ - libraryItems: libraryItems || [], + libraryItems: mergedLibraryItems, }) setIsLibraryLoaded(true) } catch (error) { @@ -328,7 +378,18 @@ export default function App({ } }, 1000) - // On unmount: Clean up all stores to prevent stale state + return () => clearInterval(fetchLibInterval) + }, [ + fileName, + fetchLibraryItems, + getInitialLibraryItems, + getInitialLibraryItemsPresent, + isLoading, + normalizedFileId, + setIsLibraryLoaded, + ]) + + useEffect(() => { return () => { // Save any pending changes before resetting stores saveOnUnmount() @@ -336,11 +397,12 @@ export default function App({ // Reset all stores resetStore() resetExcalidrawAPI() + initialLibraryItemIdsRef.current = new Set() // 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 +459,11 @@ export default function App({ return } try { - await updateLibraryItems(items) + await updateLibraryItems(items, initialLibraryItemIdsRef.current) } catch (error) { logger.error('[App] Error syncing library items:', error) } - }, [isLibraryLoaded]) + }, [isLibraryLoaded, updateLibraryItems]) const libraryReturnUrl = encodeURIComponent(window.location.href) @@ -439,6 +501,63 @@ export default function App({ return Array.from({ length: 40 }, () => Math.floor(Math.random() * 16).toString(16)).join('') }, [maxImageSizeBytes, maxImageSizeMb]) + const closeLibraryTemplateDialog = useCallback(() => { + if (isSavingLibraryTemplate) { + return + } + setLibraryTemplateDialogItems(null) + setLibraryTemplateDialogSource('library') + setLibraryTemplateName('') + setLibraryTemplateError(null) + }, [isSavingLibraryTemplate]) + + const onLibrarySaveAsTemplate = useCallback((items: LibraryItems, context?: { source?: LibraryTemplateDialogSource }) => { + if (isReadOnly || isVersionPreview) { + return + } + if (items.length === 0) { + showError(t('whiteboard', 'Add at least one library item before saving a preset.')) + return + } + setLibraryTemplateDialogItems(items) + setLibraryTemplateDialogSource(context?.source === 'selection' ? 'selection' : 'library') + setLibraryTemplateName('') + setLibraryTemplateError(null) + }, [isReadOnly, isVersionPreview]) + + const submitLibraryTemplateDialog = useCallback(async (event: FormEvent) => { + event.preventDefault() + if (!libraryTemplateDialogItems || isSavingLibraryTemplate) { + return + } + + const templateName = libraryTemplateName.trim() + if (templateName === '') { + setLibraryTemplateError(t('whiteboard', 'Enter a library preset name.')) + return + } + + setIsSavingLibraryTemplate(true) + setLibraryTemplateError(null) + try { + await saveLibraryTemplate(templateName, libraryTemplateDialogItems) + showSuccess(t('whiteboard', 'Library preset saved.')) + setLibraryTemplateDialogItems(null) + setLibraryTemplateDialogSource('library') + setLibraryTemplateName('') + } catch (error) { + const status = (error as { status?: number }).status + const message = status === 409 + ? t('whiteboard', 'A library preset with this name already exists.') + : t('whiteboard', 'Could not save library preset.') + setLibraryTemplateError(message) + showError(message) + logger.error('[App] Error saving library preset:', error) + } finally { + setIsSavingLibraryTemplate(false) + } + }, [isSavingLibraryTemplate, libraryTemplateDialogItems, libraryTemplateName, saveLibraryTemplate]) + const handleOnChange = useCallback(() => { if (isVersionPreview) { return @@ -469,6 +588,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 (
@@ -524,7 +652,7 @@ export default function App({ validateEmbeddable={() => true} renderEmbeddable={Embeddable} beforeElementCreated={beforeElementCreated} - excalidrawAPI={setExcalidrawAPI} + onExcalidrawAPI={onExcalidrawAPI} initialData={initialDataPromise} generateIdForFile={generateIdForFile} onPointerUpdate={onPointerUpdate} @@ -539,6 +667,7 @@ export default function App({ }} onLinkOpen={onLinkOpen} onLibraryChange={onLibraryChange} + onLibrarySaveAsTemplate={isReadOnly || isVersionPreview ? undefined : onLibrarySaveAsTemplate} langCode={lang} libraryReturnUrl={libraryReturnUrl} > @@ -616,6 +745,71 @@ export default function App({ settings={creatorDisplaySettings} /> )} + {libraryTemplateDialogItems && ( +
+
{ + if (event.key === 'Escape') { + event.stopPropagation() + closeLibraryTemplateDialog() + } + }}> +

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

+

+ {t('whiteboard', 'Saves reusable Library sidebar items only. The canvas is not included.')} +

+

+ {formatLibraryItemCount(libraryTemplateDialogItems.length)} +

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

+ {libraryTemplateError} +

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

+ +

+ {{ t('whiteboard', 'Upload .excalidrawlib files that users can choose when creating a new whiteboard. Presets are copied into the new board and are not linked afterwards.') }} +

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

+ {{ t('whiteboard', 'No organization library presets 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..554f61da 100644 --- a/src/hooks/useBoardDataManager.ts +++ b/src/hooks/useBoardDataManager.ts @@ -18,10 +18,25 @@ import logger from '../utils/logger' import { computeElementVersionHash, mergeSceneElements } from '../utils/syncSceneData' import { sanitizeAppStateForSync } from '../utils/sanitizeAppState' +function sanitizeLibraryItems(items: unknown): any[] { + if (!Array.isArray(items)) { + return [] + } + + return items.filter((item) => ( + item + && typeof item === 'object' + && Array.isArray((item as any).elements) + && (item as any).elements.length > 0 + )) +} + 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 +97,9 @@ export function useBoardDataManager() { }, []) const loadBoard = useCallback(async () => { + initialLibraryItemsRef.current = [] + initialLibraryItemsPresentRef.current = false + if (isVersionPreview) { try { if (!versionSource) { @@ -210,6 +228,7 @@ export function useBoardDataManager() { dataToUse = { elements: reconciledElements, files: mergedFiles, + ...(Array.isArray(serverData.libraryItems) ? { libraryItems: sanitizeLibraryItems(serverData.libraryItems) } : {}), appState: mergedAppState, scrollToContent: serverScrollToContent, } @@ -266,6 +285,9 @@ export function useBoardDataManager() { const sanitizedAppState = sanitizeAppStateForSync(dataToUse.appState) const finalAppState = { ...defaultSettings, ...sanitizedAppState } const files = dataToUse.files || {} + const libraryItems = sanitizeLibraryItems(dataToUse.libraryItems) + initialLibraryItemsRef.current = libraryItems + initialLibraryItemsPresentRef.current = Array.isArray(dataToUse.libraryItems) // Force a small delay to ensure the component is ready to receive the data const timeout = setTimeout(() => { @@ -396,9 +418,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..2f3850b6 100644 --- a/src/hooks/useLibrary.ts +++ b/src/hooks/useLibrary.ts @@ -14,6 +14,19 @@ type LibraryItemExtended = LibraryItem & { filename?: string; } +type LibrarySaveError = Error & { + status?: number +} + +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 +} + export function useLibrary() { const { getJWT } = useJWTStore( useShallow(state => ({ @@ -90,15 +103,18 @@ export function useLibrary() { logger.error('[Library] Error fetching library:', error) return null } - }) + }, [getJWT]) - const updateLibraryItems = useCallback(async (items: LibraryItems): Promise => { + const updateLibraryItems = useCallback(async (items: LibraryItems, excludedItemIds: Set = new Set()): Promise => { try { const jwt = await getJWT() if (!jwt) { logger.warn('[Library] No JWT found, cannot update library') return } + const itemsToSave = excludedItemIds.size === 0 + ? items + : items.filter(item => !item.id || !excludedItemIds.has(item.id)) const url = generateUrl('apps/whiteboard/library') const response = await globalThis.fetch(url, { method: 'PUT', @@ -107,7 +123,7 @@ export function useLibrary() { 'X-Requested-With': 'XMLHttpRequest', Authorization: `Bearer ${jwt}`, }, - body: JSON.stringify({ items }), + body: JSON.stringify({ items: itemsToSave }), }) if (!response.ok) { @@ -116,11 +132,40 @@ export function useLibrary() { } 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 preset') + 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 preset: ${response.statusText}`) as LibrarySaveError + error.status = response.status + throw error + } + }, [getJWT]) return { fetchLibraryItems, updateLibraryItems, + saveLibraryTemplate, isLibraryLoaded, setIsLibraryLoaded, } diff --git a/src/styles/globals/_layout.scss b/src/styles/globals/_layout.scss index 7e305384..5e532d54 100644 --- a/src/styles/globals/_layout.scss +++ b/src/styles/globals/_layout.scss @@ -237,6 +237,113 @@ } } +.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__input--error { + border-color: var(--color-border-error, var(--color-text-error, var(--color-error, #d93025))); + } +} + +.library-template-dialog__hint, +.library-template-dialog__count { + color: var(--color-text-maxcontrast); + line-height: 1.4; +} + +.library-template-dialog__error { + color: var(--color-text-error, var(--color-error, #d93025)); + font-weight: 600; + line-height: 1.4; +} + +.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/tests/Unit/AppInfo/ApplicationTest.php b/tests/Unit/AppInfo/ApplicationTest.php index be02ba36..23568342 100644 --- a/tests/Unit/AppInfo/ApplicationTest.php +++ b/tests/Unit/AppInfo/ApplicationTest.php @@ -10,10 +10,19 @@ namespace OCA\Whiteboard\AppInfo; +use OCA\Whiteboard\Template\GlobalLibraryTemplateProvider; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Util; + class ApplicationTest extends \Test\TestCase { public function testApp(): void { - $registrationContext = $this->createMock(\OCP\AppFramework\Bootstrap\IRegistrationContext::class); + $registrationContext = $this->createMock(IRegistrationContext::class); + [$major] = Util::getVersion(); + $registrationContext->expects($major >= 30 ? $this->once() : $this->never()) + ->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..f6bc7140 --- /dev/null +++ b/tests/Unit/Listener/FileCreatedFromTemplateListenerTest.php @@ -0,0 +1,87 @@ +createMock(File::class); + $template->method('getName')->willReturn('Org library preset - Flowchart.excalidrawlib'); + $template->method('getPath')->willReturn('/appdata_123/whiteboard/global-libraries/Org library preset - 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 + && !isset($data['libraryMode']) + && !isset($data['librarySource']) + && count($data['libraryItems']) === 1 + && $data['libraryItems'][0]['id'] === 'item-1'; + })); + + $listener = new FileCreatedFromTemplateListener( + $this->createLibraryService(), + $this->createMock(LoggerInterface::class), + ); + $listener->handle(new FileCreatedFromTemplateEvent($template, $target, [])); + } + + public function testIgnoresUserLibraryTemplates(): void { + $template = $this->createMock(File::class); + $template->method('getName')->willReturn('My preset.excalidrawlib'); + $template->method('getPath')->willReturn('/admin/files/Templates/My preset.excalidrawlib'); + + $target = $this->createMock(File::class); + $target->method('getName')->willReturn('New whiteboard.whiteboard'); + $target->expects($this->never())->method('putContent'); + + $listener = new FileCreatedFromTemplateListener( + $this->createLibraryService(), + $this->createMock(LoggerInterface::class), + ); + $listener->handle(new FileCreatedFromTemplateEvent($template, $target, [])); + } + + private function createLibraryService(): WhiteboardLibraryService { + return new WhiteboardLibraryService( + $this->createMock(ITemplateManager::class), + $this->createMock(IRootFolder::class), + $this->createMock(IConfig::class), + $this->createMock(LoggerInterface::class), + ); + } +} diff --git a/tests/Unit/Service/WhiteboardContentServiceTest.php b/tests/Unit/Service/WhiteboardContentServiceTest.php new file mode 100644 index 00000000..360a6b0c --- /dev/null +++ b/tests/Unit/Service/WhiteboardContentServiceTest.php @@ -0,0 +1,57 @@ +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, + ], + ]); + } +}