diff --git a/appinfo/routes.php b/appinfo/routes.php
index 3e8e0cb3..0dfaa0db 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -9,6 +9,7 @@
use OCA\Whiteboard\Controller\AiController;
use OCA\Whiteboard\Controller\JWTController;
+use OCA\Whiteboard\Controller\PickerController;
use OCA\Whiteboard\Controller\RecordingController;
use OCA\Whiteboard\Controller\SettingsController;
use OCA\Whiteboard\Controller\WhiteboardController;
@@ -23,6 +24,18 @@
['name' => 'Whiteboard#getLib', 'url' => 'library', 'verb' => 'GET'],
/** @see WhiteboardController::updateLib() */
['name' => 'Whiteboard#updateLib', 'url' => 'library', 'verb' => 'PUT'],
+ /** @see WhiteboardController::listLibraries() */
+ ['name' => 'Whiteboard#listLibraries', 'url' => 'libraries', 'verb' => 'GET'],
+ /** @see WhiteboardController::resolveLibrary() */
+ ['name' => 'Whiteboard#resolveLibrary', 'url' => 'libraries/resolve', 'verb' => 'GET'],
+ /** @see WhiteboardController::saveLibrary() */
+ ['name' => 'Whiteboard#saveLibrary', 'url' => 'libraries', 'verb' => 'POST'],
+ /** @see WhiteboardController::deleteLibrary() */
+ ['name' => 'Whiteboard#deleteLibrary', 'url' => 'libraries/{scope}/{name}', 'verb' => 'DELETE'],
+ /** @see WhiteboardController::publishCanvasTemplate() */
+ ['name' => 'Whiteboard#publishCanvasTemplate', 'url' => 'canvas-template', 'verb' => 'POST'],
+ /** @see PickerController::index() */
+ ['name' => 'Picker#index', 'url' => 'picker', 'verb' => 'GET'],
/** @see WhiteboardController::update() */
['name' => 'Whiteboard#update', 'url' => '{fileId}', 'verb' => 'PUT'],
/** @see WhiteboardController::show() */
@@ -35,5 +48,17 @@
['name' => 'Settings#update', 'url' => 'settings', 'verb' => 'POST'],
/** @see SettingsController::updatePersonal() */
['name' => 'Settings#updatePersonal', 'url' => 'settings/personal', 'verb' => 'POST'],
+ /** @see SettingsController::listOrgLibraries() */
+ ['name' => 'Settings#listOrgLibraries', 'url' => 'settings/org-library', 'verb' => 'GET'],
+ /** @see SettingsController::uploadOrgLibrary() */
+ ['name' => 'Settings#uploadOrgLibrary', 'url' => 'settings/org-library', 'verb' => 'POST'],
+ /** @see SettingsController::deleteOrgLibrary() */
+ ['name' => 'Settings#deleteOrgLibrary', 'url' => 'settings/org-library/{name}', 'verb' => 'DELETE'],
+ /** @see SettingsController::listOrgCanvasTemplates() */
+ ['name' => 'Settings#listOrgCanvasTemplates', 'url' => 'settings/org-canvas-template', 'verb' => 'GET'],
+ /** @see SettingsController::uploadOrgCanvasTemplate() */
+ ['name' => 'Settings#uploadOrgCanvasTemplate', 'url' => 'settings/org-canvas-template', 'verb' => 'POST'],
+ /** @see SettingsController::deleteOrgCanvasTemplate() */
+ ['name' => 'Settings#deleteOrgCanvasTemplate', 'url' => 'settings/org-canvas-template/{name}', 'verb' => 'DELETE'],
]
];
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index c4627779..9d9199d5 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -9,15 +9,18 @@
namespace OCA\Whiteboard\AppInfo;
+use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent;
use OCA\Viewer\Event\LoadViewer;
use OCA\Whiteboard\Listener\AddContentSecurityPolicyListener;
use OCA\Whiteboard\Listener\BeforeTemplateRenderedListener;
+use OCA\Whiteboard\Listener\FilesLoadAdditionalScriptsListener;
use OCA\Whiteboard\Listener\LoadTextEditorListener;
use OCA\Whiteboard\Listener\LoadViewerListener;
use OCA\Whiteboard\Listener\RegisterDirectEditorListener;
use OCA\Whiteboard\Listener\RegisterTemplateCreatorListener;
use OCA\Whiteboard\Settings\SetupCheck;
+use OCA\Whiteboard\Template\GlobalTemplateProvider;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -50,6 +53,14 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateCreatorListener::class);
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
$context->registerEventListener(RegisterDirectEditorEvent::class, RegisterDirectEditorListener::class);
+
+ [$major] = Util::getVersion();
+ if ($major >= 30) {
+ $context->registerTemplateProvider(GlobalTemplateProvider::class);
+ // The picker enhancer only targets the template picker markup that
+ // ships alongside the template provider API gated above.
+ $context->registerEventListener(LoadAdditionalScriptsEvent::class, FilesLoadAdditionalScriptsListener::class);
+ }
$context->registerSetupCheck(SetupCheck::class);
}
diff --git a/lib/Controller/PickerController.php b/lib/Controller/PickerController.php
new file mode 100644
index 00000000..3955f206
--- /dev/null
+++ b/lib/Controller/PickerController.php
@@ -0,0 +1,102 @@
+libraryService->getOrgLibraryPointerFiles() as $file) {
+ $entries[(string)$file->getId()] = ['kind' => self::KIND_LIBRARY, 'scope' => WhiteboardFolderService::SCOPE_ORG];
+ }
+ } catch (\Throwable $e) {
+ $this->logger->warning('Failed to list organization library pointers for picker', ['app' => 'whiteboard', 'exception' => $e]);
+ }
+
+ try {
+ foreach ($this->canvasTemplateService->getOrgCanvasTemplateFiles() as $file) {
+ $entries[(string)$file->getId()] = ['kind' => self::KIND_CANVAS_TEMPLATE, 'scope' => WhiteboardFolderService::SCOPE_ORG];
+ }
+ } catch (\Throwable $e) {
+ $this->logger->warning('Failed to list organization canvas templates for picker', ['app' => 'whiteboard', 'exception' => $e]);
+ }
+
+ try {
+ $uid = $this->userSession->getUser()?->getUID();
+ if ($uid !== null && $uid !== '') {
+ foreach ($this->folders->getUserTemplateFolder($uid)->getDirectoryListing() as $node) {
+ if (!$node instanceof File || !str_ends_with(strtolower($node->getName()), '.whiteboard')) {
+ continue;
+ }
+ $entries[(string)$node->getId()] = [
+ 'kind' => $this->isLibraryPointer($node) ? self::KIND_LIBRARY : self::KIND_CANVAS_TEMPLATE,
+ 'scope' => WhiteboardFolderService::SCOPE_PERSONAL,
+ ];
+ }
+ }
+ } catch (\Throwable $e) {
+ $this->logger->warning('Failed to list personal templates for picker', ['app' => 'whiteboard', 'exception' => $e]);
+ }
+
+ return new DataResponse(['entries' => $entries]);
+ }
+
+ private function isLibraryPointer(File $file): bool {
+ if ($file->getSize() > self::MAX_POINTER_BYTES) {
+ return false;
+ }
+ try {
+ $content = json_decode($file->getContent(), true, 16);
+ } catch (\Throwable) {
+ return false;
+ }
+ return is_array($content) && isset($content['libraryRef']);
+ }
+}
diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php
index 3b46cc88..aacbeee6 100644
--- a/lib/Controller/SettingsController.php
+++ b/lib/Controller/SettingsController.php
@@ -10,11 +10,14 @@
namespace OCA\Whiteboard\Controller;
use Exception;
+use OCA\Whiteboard\Service\CanvasTemplateService;
use OCA\Whiteboard\Service\ConfigService;
use OCA\Whiteboard\Service\ExceptionService;
use OCA\Whiteboard\Service\JWTService;
+use OCA\Whiteboard\Service\WhiteboardLibraryService;
use OCA\Whiteboard\Settings\SetupCheck;
use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
use OCP\IUserSession;
@@ -31,6 +34,8 @@ public function __construct(
private ConfigService $configService,
private SetupCheck $setupCheck,
private IUserSession $userSession,
+ private WhiteboardLibraryService $libraryService,
+ private CanvasTemplateService $canvasTemplateService,
) {
parent::__construct('whiteboard', $request);
}
@@ -91,4 +96,94 @@ public function updatePersonal(): DataResponse {
return $this->exceptionService->handleException($e);
}
}
+
+ public function listOrgLibraries(): DataResponse {
+ try {
+ return new DataResponse(['libraries' => $this->libraryService->listLibraries($this->uid())['org']]);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
+ public function uploadOrgLibrary(): DataResponse {
+ try {
+ $uploadedFile = $this->request->getUploadedFile('file');
+ if (!is_array($uploadedFile) || !isset($uploadedFile['tmp_name'], $uploadedFile['name'])) {
+ throw new Exception('No library uploaded', Http::STATUS_BAD_REQUEST);
+ }
+ if (($uploadedFile['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
+ throw new Exception('Library upload failed', Http::STATUS_BAD_REQUEST);
+ }
+
+ $content = file_get_contents($uploadedFile['tmp_name']);
+ if ($content === false) {
+ throw new Exception('Failed to read uploaded library', Http::STATUS_BAD_REQUEST);
+ }
+
+ $name = (string)preg_replace('/\.excalidrawlib$/i', '', (string)$uploadedFile['name']);
+ $items = $this->libraryService->parseLibraryFile($content);
+
+ return new DataResponse([
+ 'library' => $this->libraryService->saveLibrary($this->uid(), 'org', $name, $items),
+ ], Http::STATUS_CREATED);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
+ public function deleteOrgLibrary(string $name): DataResponse {
+ try {
+ $this->libraryService->deleteLibrary($this->uid(), 'org', $name);
+ return new DataResponse(['status' => 'success']);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
+ public function listOrgCanvasTemplates(): DataResponse {
+ try {
+ return new DataResponse(['canvasTemplates' => $this->canvasTemplateService->listOrgCanvasTemplates()]);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
+ public function uploadOrgCanvasTemplate(): DataResponse {
+ try {
+ $uploadedFile = $this->request->getUploadedFile('file');
+ if (!is_array($uploadedFile) || !isset($uploadedFile['tmp_name'], $uploadedFile['name'])) {
+ throw new Exception('No canvas uploaded', Http::STATUS_BAD_REQUEST);
+ }
+ if (($uploadedFile['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
+ throw new Exception('Canvas upload failed', Http::STATUS_BAD_REQUEST);
+ }
+
+ $content = file_get_contents($uploadedFile['tmp_name']);
+ if ($content === false) {
+ throw new Exception('Failed to read uploaded canvas', Http::STATUS_BAD_REQUEST);
+ }
+
+ $name = (string)preg_replace('/\.whiteboard$/i', '', (string)$uploadedFile['name']);
+ $data = $this->canvasTemplateService->parseCanvasTemplateData($content);
+
+ return new DataResponse([
+ 'canvasTemplate' => $this->canvasTemplateService->publishCanvasTemplate($this->uid(), 'org', $name, $data),
+ ], Http::STATUS_CREATED);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
+ public function deleteOrgCanvasTemplate(string $name): DataResponse {
+ try {
+ $this->canvasTemplateService->deleteOrgCanvasTemplate($name);
+ return new DataResponse(['status' => 'success']);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
+ private function uid(): string {
+ return $this->userSession->getUser()?->getUID() ?? '';
+ }
}
diff --git a/lib/Controller/WhiteboardController.php b/lib/Controller/WhiteboardController.php
index c16f4473..dfa4c5c1 100644
--- a/lib/Controller/WhiteboardController.php
+++ b/lib/Controller/WhiteboardController.php
@@ -10,9 +10,11 @@
namespace OCA\Whiteboard\Controller;
use Exception;
+use InvalidArgumentException;
use OCA\Whiteboard\Exception\InvalidUserException;
use OCA\Whiteboard\Exception\UnauthorizedException;
use OCA\Whiteboard\Service\Authentication\GetUserFromIdServiceFactory;
+use OCA\Whiteboard\Service\CanvasTemplateService;
use OCA\Whiteboard\Service\ConfigService;
use OCA\Whiteboard\Service\ExceptionService;
use OCA\Whiteboard\Service\File\GetFileServiceFactory;
@@ -20,11 +22,13 @@
use OCA\Whiteboard\Service\WhiteboardContentService;
use OCA\Whiteboard\Service\WhiteboardLibraryService;
use OCP\AppFramework\ApiController;
+use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\ICacheFactory;
+use OCP\IGroupManager;
use OCP\IMemcache;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
@@ -44,10 +48,12 @@ public function __construct(
private JWTService $jwtService,
private WhiteboardContentService $contentService,
private WhiteboardLibraryService $libraryService,
+ private CanvasTemplateService $canvasTemplateService,
private ExceptionService $exceptionService,
private ConfigService $configService,
private LoggerInterface $logger,
private ICacheFactory $cacheFactory,
+ private IGroupManager $groupManager,
) {
parent::__construct($appName, $request);
$this->cache = $cacheFactory->createLocking('whiteboard_sync');
@@ -108,8 +114,8 @@ public function update(int $fileId, array $data): DataResponse {
public function getLib(): DataResponse {
try {
$jwt = $this->getJwtFromRequest();
- $this->jwtService->getUserIdFromJWT($jwt);
- $data = $this->libraryService->getUserLib();
+ $userId = $this->jwtService->getUserIdFromJWT($jwt);
+ $data = $this->libraryService->getUserLib($userId);
return new DataResponse(['data' => $data]);
} catch (Exception $e) {
@@ -133,6 +139,99 @@ public function updateLib(): DataResponse {
}
}
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ #[PublicPage]
+ public function listLibraries(): DataResponse {
+ try {
+ $jwt = $this->getJwtFromRequest();
+ $userId = $this->jwtService->getUserIdFromJWT($jwt);
+ return new DataResponse(['data' => $this->libraryService->listLibraries($userId)]);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ #[PublicPage]
+ public function resolveLibrary(): DataResponse {
+ try {
+ $jwt = $this->getJwtFromRequest();
+ $userId = $this->jwtService->getUserIdFromJWT($jwt);
+ $scope = (string)$this->request->getParam('scope', '');
+ $name = (string)$this->request->getParam('name', '');
+ return new DataResponse(['data' => $this->libraryService->resolveLibrary($userId, $scope, $name)]);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ #[PublicPage]
+ public function saveLibrary(): DataResponse {
+ try {
+ $jwt = $this->getJwtFromRequest();
+ $userId = $this->jwtService->getUserIdFromJWT($jwt);
+ $scope = (string)$this->request->getParam('scope', 'personal');
+ $name = (string)$this->request->getParam('name', '');
+ $items = $this->request->getParam('items', []);
+ if (!is_array($items)) {
+ throw new InvalidArgumentException('Invalid library items', Http::STATUS_BAD_REQUEST);
+ }
+ if ($scope === 'org' && !$this->groupManager->isAdmin($userId)) {
+ throw new InvalidArgumentException('Only administrators can save organization libraries', Http::STATUS_FORBIDDEN);
+ }
+ return new DataResponse(['library' => $this->libraryService->saveLibrary($userId, $scope, $name, $items)]);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ #[PublicPage]
+ public function deleteLibrary(string $scope, string $name): DataResponse {
+ try {
+ $jwt = $this->getJwtFromRequest();
+ $userId = $this->jwtService->getUserIdFromJWT($jwt);
+ if ($scope === 'org' && !$this->groupManager->isAdmin($userId)) {
+ throw new InvalidArgumentException('Only administrators can delete organization libraries', Http::STATUS_FORBIDDEN);
+ }
+ $this->libraryService->deleteLibrary($userId, $scope, $name);
+ return new DataResponse(['status' => 'success']);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ #[PublicPage]
+ public function publishCanvasTemplate(): DataResponse {
+ try {
+ $jwt = $this->getJwtFromRequest();
+ $userId = $this->jwtService->getUserIdFromJWT($jwt);
+ $scope = (string)$this->request->getParam('scope', 'personal');
+ $name = (string)$this->request->getParam('name', '');
+ $data = $this->request->getParam('data');
+ if (!is_array($data)) {
+ throw new InvalidArgumentException('Invalid canvas template data', Http::STATUS_BAD_REQUEST);
+ }
+ if ($scope === 'org' && !$this->groupManager->isAdmin($userId)) {
+ throw new InvalidArgumentException('Only administrators can publish organization canvas templates', Http::STATUS_FORBIDDEN);
+ }
+ $parsed = $this->canvasTemplateService->parseCanvasTemplateData($data);
+ return new DataResponse(
+ ['canvasTemplate' => $this->canvasTemplateService->publishCanvasTemplate($userId, $scope, $name, $parsed)],
+ Http::STATUS_CREATED
+ );
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
private function getJwtFromRequest(): string {
$authHeader = $this->request->getHeader('Authorization');
if (sscanf($authHeader, 'Bearer %s', $jwt) !== 1) {
diff --git a/lib/Listener/BeforeTemplateRenderedListener.php b/lib/Listener/BeforeTemplateRenderedListener.php
index 71c7b0de..4e07ba8f 100644
--- a/lib/Listener/BeforeTemplateRenderedListener.php
+++ b/lib/Listener/BeforeTemplateRenderedListener.php
@@ -17,6 +17,7 @@
use OCP\EventDispatcher\IEventListener;
use OCP\Files\File;
use OCP\Files\NotFoundException;
+use OCP\IGroupManager;
use OCP\IUserSession;
use OCP\Util;
@@ -31,6 +32,7 @@ public function __construct(
private ConfigService $configService,
private IEventDispatcher $eventDispatcher,
private IUserSession $userSession,
+ private IGroupManager $groupManager,
) {
}
@@ -74,5 +76,10 @@ public function handle(Event $event): void {
'autoUploadOnDisconnect',
$user ? $this->configService->getUserAutoUploadOnDisconnect($user->getUID()) : false,
);
+ $this->initialState->provideInitialState(
+ 'isAdmin',
+ $user !== null && $this->groupManager->isAdmin($user->getUID()),
+ );
+ $this->initialState->provideInitialState('orgTemplatesSupported', Util::getVersion()[0] >= 30);
}
}
diff --git a/lib/Listener/FilesLoadAdditionalScriptsListener.php b/lib/Listener/FilesLoadAdditionalScriptsListener.php
new file mode 100644
index 00000000..7d7ff31c
--- /dev/null
+++ b/lib/Listener/FilesLoadAdditionalScriptsListener.php
@@ -0,0 +1,36 @@
+
+ * @psalm-suppress UndefinedClass
+ * @psalm-suppress UndefinedDocblockClass
+ */
+final class FilesLoadAdditionalScriptsListener implements IEventListener {
+ #[\Override]
+ public function handle(Event $event): void {
+ if (!($event instanceof LoadAdditionalScriptsEvent)) {
+ return;
+ }
+
+ Util::addInitScript(Application::APP_ID, 'whiteboard-files');
+ }
+}
diff --git a/lib/Listener/LoadViewerListener.php b/lib/Listener/LoadViewerListener.php
index 63ad0892..7e587b4b 100644
--- a/lib/Listener/LoadViewerListener.php
+++ b/lib/Listener/LoadViewerListener.php
@@ -14,6 +14,7 @@
use OCP\AppFramework\Services\IInitialState;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
+use OCP\IGroupManager;
use OCP\IUserSession;
use OCP\Util;
@@ -23,6 +24,7 @@ public function __construct(
private IInitialState $initialState,
private ConfigService $configService,
private IUserSession $userSession,
+ private IGroupManager $groupManager,
) {
}
@@ -52,5 +54,10 @@ public function handle(Event $event): void {
'autoUploadOnDisconnect',
$user ? $this->configService->getUserAutoUploadOnDisconnect($user->getUID()) : false
);
+ $this->initialState->provideInitialState(
+ 'isAdmin',
+ $user !== null && $this->groupManager->isAdmin($user->getUID())
+ );
+ $this->initialState->provideInitialState('orgTemplatesSupported', Util::getVersion()[0] >= 30);
}
}
diff --git a/lib/Listener/RegisterTemplateCreatorListener.php b/lib/Listener/RegisterTemplateCreatorListener.php
index a319ddf9..6511b045 100644
--- a/lib/Listener/RegisterTemplateCreatorListener.php
+++ b/lib/Listener/RegisterTemplateCreatorListener.php
@@ -40,7 +40,6 @@ public function handle(Event $event): void {
public static function getTemplateFileCreator(IL10N $l10n): TemplateFileCreator {
$whiteboard = new TemplateFileCreator(Application::APP_ID, $l10n->t('New whiteboard'), '.whiteboard');
$whiteboard->addMimetype('application/vnd.excalidraw+json');
- $whiteboard->addMimetype('application/octet-stream');
// Always use the custom SVG icon for consistency
$iconContent = file_get_contents(__DIR__ . '/../../img/app-filetype.svg');
diff --git a/lib/Service/CanvasTemplateService.php b/lib/Service/CanvasTemplateService.php
new file mode 100644
index 00000000..10cc8263
--- /dev/null
+++ b/lib/Service/CanvasTemplateService.php
@@ -0,0 +1,217 @@
+ $raw
+ *
+ * @return array{elements: array, files: array, appState?: array, scrollToContent: true}
+ */
+ public function parseCanvasTemplateData(string|array $raw): array {
+ if (is_string($raw)) {
+ if (strlen($raw) > self::MAX_CANVAS_TEMPLATE_BYTES) {
+ throw new InvalidArgumentException('Canvas is too large (max 15 MB).', Http::STATUS_BAD_REQUEST);
+ }
+ try {
+ $raw = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException) {
+ throw new InvalidArgumentException('This is not a valid whiteboard file.', Http::STATUS_BAD_REQUEST);
+ }
+ }
+ if (!is_array($raw)) {
+ throw new InvalidArgumentException('This is not a valid whiteboard file.', Http::STATUS_BAD_REQUEST);
+ }
+
+ $elements = [];
+ foreach (is_array($raw['elements'] ?? null) ? $raw['elements'] : [] as $element) {
+ if (is_array($element)) {
+ $elements[] = $element;
+ }
+ }
+ if (count($elements) === 0) {
+ throw new InvalidArgumentException('This whiteboard has no content to save as a canvas.', Http::STATUS_BAD_REQUEST);
+ }
+
+ $files = [];
+ foreach (is_array($raw['files'] ?? null) ? $raw['files'] : [] as $key => $file) {
+ if (is_array($file)) {
+ $files[(string)$key] = $file;
+ }
+ }
+
+ $data = [
+ 'elements' => $elements,
+ 'files' => $files,
+ 'scrollToContent' => true,
+ ];
+
+ if (isset($raw['appState']) && is_array($raw['appState'])) {
+ $appState = $raw['appState'];
+ unset($appState['collaborators'], $appState['selectedElementIds']);
+ if (!empty($appState)) {
+ $data['appState'] = $appState;
+ }
+ }
+
+ try {
+ $encoded = json_encode($data, JSON_THROW_ON_ERROR);
+ } catch (JsonException) {
+ throw new InvalidArgumentException('This is not a valid whiteboard file.', Http::STATUS_BAD_REQUEST);
+ }
+ if (strlen($encoded) > self::MAX_CANVAS_TEMPLATE_BYTES) {
+ throw new InvalidArgumentException('Canvas is too large (max 15 MB).', Http::STATUS_BAD_REQUEST);
+ }
+
+ return $data;
+ }
+
+ /**
+ * Write a canvas template file from canonical board data (see parseCanvasTemplateData).
+ *
+ * @param array $data
+ *
+ * @return array{name: string, scope: string}
+ *
+ * @throws NotPermittedException
+ * @throws NotFoundException
+ * @throws GenericFileException
+ */
+ public function publishCanvasTemplate(string $uid, string $scope, string $name, array $data): array {
+ $scope = $this->folders->normalizeScope($scope);
+ $this->folders->assertScopeWritable($scope);
+ $name = $this->folders->normalizeName($name);
+ $this->folders->enforceFilenamePolicy($name, self::WHITEBOARD_EXTENSION);
+
+ $folder = $scope === self::SCOPE_ORG
+ ? $this->getOrgCanvasTemplateFolder(true)
+ : $this->folders->getUserTemplateFolder($uid);
+ if (!$folder instanceof Folder) {
+ throw new NotFoundException('Canvas template folder not available');
+ }
+
+ $fileName = $name . self::WHITEBOARD_EXTENSION;
+ $file = $folder->nodeExists($fileName) ? $folder->get($fileName) : $folder->newFile($fileName);
+ if (!$file instanceof File) {
+ throw new GenericFileException('Failed to create or get canvas template: ' . $fileName);
+ }
+ // Personal canvas templates share the Templates folder with library
+ // pointer boards — never overwrite a library's pointer.
+ if ($this->folders->isLibraryPointer($file)) {
+ throw new InvalidArgumentException('A library named "' . $name . '" already exists. Choose a different name.', Http::STATUS_CONFLICT);
+ }
+ $file->putContent(json_encode($data, JSON_THROW_ON_ERROR));
+
+ return ['name' => $name, 'scope' => $scope];
+ }
+
+ /**
+ * @return array
+ */
+ public function listOrgCanvasTemplates(): array {
+ $templates = [];
+ foreach ($this->getOrgCanvasTemplateFiles() as $file) {
+ $elementCount = 0;
+ try {
+ $content = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR);
+ if (is_array($content) && isset($content['elements']) && is_array($content['elements'])) {
+ $elementCount = count($content['elements']);
+ }
+ } catch (JsonException) {
+ $this->logger->warning('Skipping malformed whiteboard canvas template', [
+ 'app' => 'whiteboard',
+ 'file' => $file->getName(),
+ ]);
+ }
+ $templates[] = [
+ 'name' => $this->canvasTemplateNameFromFile($file->getName()),
+ 'elementCount' => $elementCount,
+ 'sizeBytes' => (int)$file->getSize(),
+ ];
+ }
+ usort($templates, static fn (array $a, array $b): int => strcasecmp($a['name'], $b['name']));
+ return $templates;
+ }
+
+ /**
+ * @throws NotPermittedException
+ * @throws NotFoundException
+ */
+ public function deleteOrgCanvasTemplate(string $name): void {
+ $name = $this->folders->normalizeName($name);
+ $this->folders->deleteIfExists($this->getOrgCanvasTemplateFolder(false), $name . self::WHITEBOARD_EXTENSION);
+ }
+
+ /**
+ * Organization canvas template files for the picker.
+ *
+ * @return array
+ */
+ public function getOrgCanvasTemplateFiles(): array {
+ $folder = $this->getOrgCanvasTemplateFolder(false);
+ if (!$folder instanceof Folder) {
+ return [];
+ }
+ $files = [];
+ foreach ($folder->getDirectoryListing() as $node) {
+ if ($node instanceof File && str_ends_with(strtolower($node->getName()), self::WHITEBOARD_EXTENSION)) {
+ $files[] = $node;
+ }
+ }
+ return $files;
+ }
+
+ public function canvasTemplateNameFromFile(string $fileName): string {
+ return str_ends_with(strtolower($fileName), self::WHITEBOARD_EXTENSION)
+ ? substr($fileName, 0, -strlen(self::WHITEBOARD_EXTENSION))
+ : $fileName;
+ }
+
+ private function getOrgCanvasTemplateFolder(bool $create): ?Folder {
+ return $this->folders->getAppDataFolder(self::ORG_CANVAS_TEMPLATE_DIR, $create);
+ }
+}
diff --git a/lib/Service/WhiteboardContentService.php b/lib/Service/WhiteboardContentService.php
index b67c8807..d3a04a30 100644
--- a/lib/Service/WhiteboardContentService.php
+++ b/lib/Service/WhiteboardContentService.php
@@ -167,6 +167,10 @@ private function normalizeIncomingData(array $incoming): array {
: [];
}
+ if (array_key_exists('libraryItems', $incoming) && is_array($incoming['libraryItems'])) {
+ $normalized['libraryItems'] = $this->sanitizeLibraryItems($incoming['libraryItems']);
+ }
+
if (array_key_exists('appState', $incoming) && is_array($incoming['appState'])) {
$normalized['appState'] = $this->sanitizeAppState($incoming['appState']);
}
@@ -175,6 +179,18 @@ private function normalizeIncomingData(array $incoming): array {
$normalized['scrollToContent'] = (bool)$incoming['scrollToContent'];
}
+ if (array_key_exists('libraryRef', $incoming)) {
+ if ($incoming['libraryRef'] === null) {
+ // Explicit removal marker; mergeData() drops the stored ref.
+ $normalized['libraryRef'] = null;
+ } else {
+ $ref = $this->sanitizeLibraryRef($incoming['libraryRef']);
+ if ($ref !== null) {
+ $normalized['libraryRef'] = $ref;
+ }
+ }
+ }
+
return $normalized;
}
@@ -182,6 +198,10 @@ private function normalizeIncomingData(array $incoming): array {
* @param array $payload
*/
private function isEffectivelyEmptyPayload(array $payload): bool {
+ if (array_key_exists('libraryItems', $payload)) {
+ return false;
+ }
+
$hasFiles = array_key_exists('files', $payload)
&& is_array($payload['files'])
&& !empty($payload['files']);
@@ -211,7 +231,7 @@ private function isEffectivelyEmptyPayload(array $payload): bool {
}
foreach ($payload as $key => $_value) {
- if (!in_array($key, ['elements', 'files', 'appState', 'scrollToContent'], true)) {
+ if (!in_array($key, ['elements', 'files', 'libraryItems', 'appState', 'scrollToContent'], true)) {
return false;
}
}
@@ -243,6 +263,10 @@ private function normalizeStoredData(array $stored): array {
$normalized['files'] = $this->sanitizeFiles($stored['files']);
}
+ if (array_key_exists('libraryItems', $stored) && is_array($stored['libraryItems'])) {
+ $normalized['libraryItems'] = $this->sanitizeLibraryItems($stored['libraryItems']);
+ }
+
if (array_key_exists('appState', $stored) && is_array($stored['appState'])) {
$normalized['appState'] = $this->sanitizeAppState($stored['appState']);
} elseif (array_key_exists('appState', $stored) && $stored['appState'] === null) {
@@ -253,6 +277,13 @@ private function normalizeStoredData(array $stored): array {
$normalized['scrollToContent'] = (bool)$stored['scrollToContent'];
}
+ if (array_key_exists('libraryRef', $stored)) {
+ $ref = $this->sanitizeLibraryRef($stored['libraryRef']);
+ if ($ref !== null) {
+ $normalized['libraryRef'] = $ref;
+ }
+ }
+
return $normalized;
}
@@ -273,6 +304,10 @@ private function mergeData(array $current, array $incoming): array {
$merged['files'] = $incoming['files'];
}
+ if (array_key_exists('libraryItems', $incoming)) {
+ $merged['libraryItems'] = $incoming['libraryItems'];
+ }
+
if (array_key_exists('appState', $incoming)) {
if ($incoming['appState'] === null) {
unset($merged['appState']);
@@ -285,6 +320,17 @@ private function mergeData(array $current, array $incoming): array {
$merged['scrollToContent'] = (bool)$incoming['scrollToContent'];
}
+ if (array_key_exists('libraryRef', $incoming)) {
+ if ($incoming['libraryRef'] === null) {
+ unset($merged['libraryRef']);
+ } else {
+ $ref = $this->sanitizeLibraryRef($incoming['libraryRef']);
+ if ($ref !== null) {
+ $merged['libraryRef'] = $ref;
+ }
+ }
+ }
+
return $merged;
}
@@ -330,6 +376,27 @@ private function sanitizeFiles(array $files): array {
return $sanitized;
}
+ /**
+ * @param array $items
+ *
+ * @return array
+ */
+ private function sanitizeLibraryItems(array $items): array {
+ $sanitized = [];
+
+ foreach ($items as $item) {
+ if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements']) || count($item['elements']) === 0) {
+ continue;
+ }
+
+ unset($item['libraryName'], $item['scope'], $item['filename'], $item['basename']);
+ $item['elements'] = array_values($item['elements']);
+ $sanitized[] = $item;
+ }
+
+ return $sanitized;
+ }
+
/**
* @param array $appState
*
@@ -345,6 +412,27 @@ private function sanitizeAppState(array $appState): array {
return $appState;
}
+ /**
+ * A board references at most one library by {scope, name}; the live items
+ * are resolved on open, so updating the library propagates to every board.
+ *
+ * @param mixed $ref
+ *
+ * @return array{scope: string, name: string}|null
+ */
+ private function sanitizeLibraryRef($ref): ?array {
+ if (!is_array($ref)) {
+ return null;
+ }
+ $scope = $ref['scope'] ?? null;
+ $name = $ref['name'] ?? null;
+ if (($scope === WhiteboardFolderService::SCOPE_PERSONAL || $scope === WhiteboardFolderService::SCOPE_ORG)
+ && is_string($name) && WhiteboardFolderService::isValidName($name)) {
+ return ['scope' => $scope, 'name' => $name];
+ }
+ return null;
+ }
+
/**
* @param mixed $value
*
diff --git a/lib/Service/WhiteboardFolderService.php b/lib/Service/WhiteboardFolderService.php
new file mode 100644
index 00000000..cc5eb418
--- /dev/null
+++ b/lib/Service/WhiteboardFolderService.php
@@ -0,0 +1,224 @@
+isFilenameValid = $isFilenameValid;
+ }
+
+ public function hasTemplateDirectory(): bool {
+ return $this->templateManager->hasTemplateDirectory();
+ }
+
+ /**
+ * @throws NotPermittedException
+ * @throws NotFoundException
+ */
+ public function getUserTemplateFolder(string $uid): Folder {
+ if (!$this->templateManager->hasTemplateDirectory()) {
+ $this->templateManager->initializeTemplateDirectory(null, $uid, false);
+ }
+
+ $userFolder = $this->rootFolder->getUserFolder($uid);
+ $templatesFolder = $userFolder->get($this->templateManager->getTemplatePath());
+ if (!$templatesFolder instanceof Folder) {
+ throw new NotFoundException('Templates folder not found for user: ' . $uid);
+ }
+ return $templatesFolder;
+ }
+
+ public function getAppDataFolder(string $dir, bool $create): ?Folder {
+ $instanceId = $this->config->getSystemValueString('instanceid', '');
+ if ($instanceId === '') {
+ return null;
+ }
+
+ if (!$create) {
+ try {
+ $node = $this->rootFolder->get('appdata_' . $instanceId . '/whiteboard/' . $dir);
+ } catch (NotFoundException) {
+ return null;
+ } catch (\Throwable $e) {
+ $this->logger->warning('Failed to access whiteboard app data', ['app' => 'whiteboard', 'dir' => $dir, 'exception' => $e]);
+ return null;
+ }
+ return $node instanceof Folder ? $node : null;
+ }
+
+ $appDataRoot = $this->ensureChildFolder($this->rootFolder, 'appdata_' . $instanceId);
+ $appFolder = $this->ensureChildFolder($appDataRoot, 'whiteboard');
+ return $this->ensureChildFolder($appFolder, $dir);
+ }
+
+ /**
+ * Whether a ".whiteboard" file is a library pointer — an empty board whose
+ * only purpose is to carry a libraryRef (see WhiteboardLibraryService) —
+ * as opposed to a real board / canvas template. Library pointers and
+ * personal canvas templates share the user's Templates folder, so writers
+ * and deleters of one kind must not touch the other.
+ */
+ public function isLibraryPointer(File $file): bool {
+ $raw = $file->getContent();
+ try {
+ $content = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ // A just-created file is empty — that is the expected non-pointer
+ // case. Anything else is a corrupt file we are about to treat as
+ // an overwritable canvas template, so leave a trace.
+ if ($raw !== '') {
+ $this->logger->warning('Treating malformed whiteboard file as non-pointer', [
+ 'app' => 'whiteboard',
+ 'file' => $file->getName(),
+ 'exception' => $e,
+ ]);
+ }
+ return false;
+ }
+ return is_array($content)
+ && isset($content['libraryRef']) && is_array($content['libraryRef'])
+ && ($content['elements'] ?? []) === [];
+ }
+
+ public function deleteIfExists(?Folder $folder, string $fileName): void {
+ if (!$folder instanceof Folder || !$folder->nodeExists($fileName)) {
+ return;
+ }
+ $node = $folder->get($fileName);
+ if ($node instanceof File) {
+ $node->delete();
+ }
+ }
+
+ public function normalizeScope(string $scope): string {
+ if ($scope !== self::SCOPE_PERSONAL && $scope !== self::SCOPE_ORG) {
+ throw new InvalidArgumentException('Invalid scope', Http::STATUS_BAD_REQUEST);
+ }
+ return $scope;
+ }
+
+ public function normalizeName(string $name): string {
+ $name = trim($name);
+ if (!self::isValidName($name)) {
+ throw new InvalidArgumentException(
+ strlen($name . self::LONGEST_EXTENSION) > self::MAX_FILENAME_BYTES ? 'Name is too long' : 'Invalid name',
+ Http::STATUS_BAD_REQUEST
+ );
+ }
+ return $name;
+ }
+
+ /**
+ * Enforce the instance filename policy (admin-forbidden characters,
+ * basenames, extensions) on the final filenames a save will create.
+ * Only write paths call this — resolving or deleting existing files must
+ * keep working even if the admin tightens the policy later. The validator
+ * API only exists on Nextcloud 30+.
+ */
+ public function enforceFilenamePolicy(string $name, string ...$extensions): void {
+ if (!interface_exists(\OCP\Files\IFilenameValidator::class)) {
+ return;
+ }
+ $isFilenameValid = $this->isFilenameValid;
+ if ($isFilenameValid === null) {
+ $validator = \OCP\Server::get(\OCP\Files\IFilenameValidator::class);
+ $isFilenameValid = static fn (string $filename): bool => $validator->isFilenameValid($filename);
+ }
+ foreach ($extensions as $extension) {
+ if (!$isFilenameValid($name . $extension)) {
+ throw new InvalidArgumentException('Invalid name', Http::STATUS_BAD_REQUEST);
+ }
+ }
+ }
+
+ /**
+ * Org templates only surface in the picker via the template provider API,
+ * which exists on Nextcloud 30+ — reject org writes on older servers so
+ * admins cannot create data nothing will ever show.
+ */
+ public function assertScopeWritable(string $scope): void {
+ if ($scope === self::SCOPE_ORG && \OCP\Util::getVersion()[0] < 30) {
+ throw new InvalidArgumentException('Organization templates require Nextcloud 30 or later', Http::STATUS_BAD_REQUEST);
+ }
+ }
+
+ /**
+ * Non-throwing twin of normalizeName, for callers that must drop invalid
+ * names silently (e.g. board content sanitization).
+ */
+ public static function isValidName(string $name): bool {
+ if ($name !== trim($name) || $name === '' || $name === '.' || $name === '..') {
+ return false;
+ }
+ if (str_contains($name, '/') || str_contains($name, '\\') || preg_match('/[\x00-\x1F\x7F]/', $name) === 1) {
+ return false;
+ }
+ // Windows-reserved device names break syncing the Templates folder to
+ // Windows clients (reserved with or without an extension, any case).
+ if (preg_match('/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i', $name) === 1) {
+ return false;
+ }
+ return strlen($name . self::LONGEST_EXTENSION) <= self::MAX_FILENAME_BYTES;
+ }
+
+ /**
+ * @throws NotPermittedException
+ */
+ private function ensureChildFolder(Folder $folder, string $name): Folder {
+ if (!$folder->nodeExists($name)) {
+ $folder->newFolder($name);
+ }
+ $node = $folder->get($name);
+ if (!$node instanceof Folder) {
+ throw new RuntimeException('Expected folder at ' . $name);
+ }
+ return $node;
+ }
+}
diff --git a/lib/Service/WhiteboardLibraryService.php b/lib/Service/WhiteboardLibraryService.php
index de27f938..215eca9d 100644
--- a/lib/Service/WhiteboardLibraryService.php
+++ b/lib/Service/WhiteboardLibraryService.php
@@ -9,70 +9,99 @@
namespace OCA\Whiteboard\Service;
+use InvalidArgumentException;
use JsonException;
-use OCA\Whiteboard\AppInfo\Application;
+use OCP\AppFramework\Http;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\GenericFileException;
-use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
-use OCP\Files\Template\ITemplateManager;
+use OCP\IConfig;
use OCP\Lock\LockedException;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
/**
+ * The Excalidraw palette ("My library"), plus the catalog of saved libraries.
+ *
+ * - "My library": the single writable palette per user, stored in the user's
+ * Templates folder as personal.excalidrawlib. Returned by getUserLib and the
+ * only thing written by updateUserLib.
+ *
+ * - Libraries: named shape kits a board can reference. A board stores only a
+ * {scope, name} reference (libraryRef); the live items are resolved on every
+ * open via resolveLibrary, so editing a library propagates to every board
+ * that points at it. Personal libraries live under the user's Templates
+ * folder; organization libraries live in app data and are shared with
+ * everyone. Each library has an items file (.excalidrawlib) and a
+ * pointer board file (.whiteboard holding the libraryRef) that
+ * surfaces it in the "New whiteboard" picker.
+ *
* @psalm-suppress UndefinedDocblockClass
* @psalm-suppress UndefinedClass
* @psalm-suppress MissingDependency
*/
final class WhiteboardLibraryService {
+ private const PERSONAL_FILE = 'personal.excalidrawlib';
+ private const LIBRARY_EXTENSION = '.excalidrawlib';
+ private const WHITEBOARD_EXTENSION = '.whiteboard';
+ private const PERSONAL_LIBRARY_DIR = '.whiteboard-libraries';
+ private const ORG_LIBRARY_DIR = 'libraries';
+ private const ORG_LIBRARY_POINTER_DIR = 'library-pointers';
+ private const SCOPE_ORG = WhiteboardFolderService::SCOPE_ORG;
+ private const LEGACY_MIGRATED_FLAG = 'legacy_libraries_migrated';
+
public function __construct(
- private ITemplateManager $templateManager,
- private IRootFolder $rootFolder,
+ private WhiteboardFolderService $folders,
+ private IConfig $config,
+ private LoggerInterface $logger,
) {
}
/**
+ * The writable personal library ("My library"). Returned as a single source.
+ *
+ * @return array>
+ *
* @throws NotPermittedException
+ * @throws NotFoundException
* @throws GenericFileException
- * @throws LockedException
- * @throws JsonException
*/
- public function getUserLib(): array {
- // Get the .excalidrawlib files from the /Templates directory
- $availableFileCreators = $this->templateManager->listTemplates();
- $templates = [];
- $libs = [];
-
- foreach ($availableFileCreators as $fileCreator) {
- if ($fileCreator['app'] !== Application::APP_ID) {
- continue;
- }
- $templates = $fileCreator['templates'];
- break;
+ public function getUserLib(string $uid): array {
+ if (str_starts_with($uid, 'shared_')) {
+ return [];
}
- foreach ($templates as $template) {
- $templateDetails = $template->jsonSerialize();
-
- if (str_ends_with($templateDetails['basename'], '.excalidrawlib')) {
- $fileId = $templateDetails['fileid'];
- $file = $this->rootFolder->getFirstNodeById($fileId);
+ $folder = $this->folders->getUserTemplateFolder($uid);
+ $this->migrateLegacyLibraries($uid, $folder);
+ if (!$folder->nodeExists(self::PERSONAL_FILE)) {
+ return [];
+ }
- if (!$file instanceof File) {
- continue;
- }
+ $file = $folder->get(self::PERSONAL_FILE);
+ if (!$file instanceof File) {
+ return [];
+ }
- $lib = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR);
- $lib['basename'] = $templateDetails['basename'];
- $libs[] = $lib;
- }
+ $lib = $this->decodeLibrary($file);
+ if ($lib === null) {
+ return [];
}
- return $libs;
+ $lib['filename'] = self::PERSONAL_FILE;
+ $lib['basename'] = self::PERSONAL_FILE;
+ $lib['writable'] = true;
+
+ return [$lib];
}
/**
+ * Persist the writable personal library. Only personal-origin items reach
+ * here (the editor filters out read-only library items before sending).
+ *
+ * @param array $items
+ *
* @throws NotPermittedException
* @throws NotFoundException
* @throws GenericFileException
@@ -80,56 +109,447 @@ public function getUserLib(): array {
* @throws JsonException
*/
public function updateUserLib(string $uid, array $items): void {
- // Check if the user has a Templates folder, if not create one
- if (!$this->templateManager->hasTemplateDirectory()) {
- $this->templateManager->initializeTemplateDirectory(null, $uid, false);
+ if (str_starts_with($uid, 'shared_')) {
+ return;
}
- // Update the .excalidrawlib files in the Templates directory
- $userFolder = $this->rootFolder->getUserFolder($uid);
- $templatesPath = $this->templateManager->getTemplatePath();
- $templatesFolder = $userFolder->get($templatesPath);
+ $folder = $this->folders->getUserTemplateFolder($uid);
+ $this->writeLibraryFile($folder, self::PERSONAL_FILE, $this->sanitizeLibraryItems($items));
+ }
- if (!$templatesFolder instanceof Folder) {
- throw new NotFoundException('Templates folder not found for user: ' . $uid);
+ /**
+ * The library catalog for the picker / save dialog.
+ *
+ * @return array{personal: array, org: array}
+ *
+ * @throws NotPermittedException
+ * @throws NotFoundException
+ */
+ public function listLibraries(string $uid): array {
+ // Public-share sessions must not see any libraries, org included.
+ if (str_starts_with($uid, 'shared_')) {
+ return ['personal' => [], 'org' => []];
}
-
- $files = [
- 'personal.excalidrawlib' => [
- 'type' => 'excalidrawlib',
- 'version' => 2,
- 'libraryItems' => [],
- ],
+ return [
+ 'personal' => $this->listLibrariesIn($this->getPersonalLibraryFolder($uid)),
+ 'org' => $this->listLibrariesIn($this->getOrgLibraryFolder(false)),
];
+ }
- foreach ($items as $item) {
- if (!isset($item['filename'])) {
- $files['personal.excalidrawlib']['libraryItems'][] = $item;
- } else {
- if (isset($files[$item['filename']])) {
- $files[$item['filename']]['libraryItems'][] = $item;
- } else {
- $files[$item['filename']] = [
- 'type' => 'excalidrawlib',
- 'version' => 2,
- 'libraryItems' => [$item],
- ];
+ /**
+ * Resolve a library reference to its current items (read-only section).
+ * Each item is tagged with `libraryName` = the library name.
+ *
+ * @return array
+ *
+ * @throws NotPermittedException
+ * @throws NotFoundException
+ */
+ public function resolveLibrary(string $uid, string $scope, string $name): array {
+ // Public-share sessions must not resolve any libraries, org included.
+ if (str_starts_with($uid, 'shared_')) {
+ return [];
+ }
+
+ $scope = $scope === self::SCOPE_ORG ? self::SCOPE_ORG : WhiteboardFolderService::SCOPE_PERSONAL;
+ $name = $this->folders->normalizeName($name);
+ $folder = $scope === self::SCOPE_ORG
+ ? $this->getOrgLibraryFolder(false)
+ : $this->getPersonalLibraryFolder($uid);
+
+ if (!$folder instanceof Folder) {
+ return [];
+ }
+
+ $itemsName = $name . self::LIBRARY_EXTENSION;
+ if (!$folder->nodeExists($itemsName)) {
+ return [];
+ }
+ $file = $folder->get($itemsName);
+ if (!$file instanceof File) {
+ return [];
+ }
+
+ $lib = $this->decodeLibrary($file);
+ $items = is_array($lib) && isset($lib['libraryItems']) && is_array($lib['libraryItems']) ? $lib['libraryItems'] : [];
+
+ $tagged = [];
+ foreach ($this->sanitizeLibraryItems($items) as $item) {
+ $item['libraryName'] = $name;
+ // Namespace the id: saved kits keep their source item ids, so a kit
+ // created from "My library" would otherwise collide with the
+ // originals in the editor's palette (one id, two sections).
+ $sourceId = isset($item['id']) && is_string($item['id']) && $item['id'] !== ''
+ ? $item['id']
+ : bin2hex(random_bytes(6));
+ $item['id'] = $scope . ':' . $name . ':' . $sourceId;
+ $tagged[] = $item;
+ }
+ return $tagged;
+ }
+
+ /**
+ * Create or replace a saved library from library items.
+ *
+ * @param array $items
+ *
+ * @return array{name: string, scope: string}
+ *
+ * @throws NotPermittedException
+ * @throws NotFoundException
+ * @throws GenericFileException
+ */
+ public function saveLibrary(string $uid, string $scope, string $name, array $items): array {
+ $scope = $this->folders->normalizeScope($scope);
+ $this->folders->assertScopeWritable($scope);
+ $name = $this->folders->normalizeName($name);
+ $this->folders->enforceFilenamePolicy($name, self::LIBRARY_EXTENSION, self::WHITEBOARD_EXTENSION);
+ $sanitized = $this->sanitizeLibraryItems($items);
+ if (count($sanitized) === 0) {
+ throw new InvalidArgumentException('Select at least one shape to save as a library', Http::STATUS_BAD_REQUEST);
+ }
+ if ($this->containsImageElement($sanitized)) {
+ throw new InvalidArgumentException('This library contains image items that cannot be imported by Whiteboard yet.', Http::STATUS_BAD_REQUEST);
+ }
+
+ // Resolve and check the pointer slot before writing the items file, so
+ // a name conflict cannot leave an orphaned items file behind.
+ $pointerFolder = $scope === self::SCOPE_ORG
+ ? $this->getOrgLibraryPointerFolder(true)
+ : $this->folders->getUserTemplateFolder($uid);
+ if (!$pointerFolder instanceof Folder) {
+ throw new NotFoundException('Library pointer folder not available');
+ }
+ $pointerName = $name . self::WHITEBOARD_EXTENSION;
+ if ($pointerFolder->nodeExists($pointerName)) {
+ $existing = $pointerFolder->get($pointerName);
+ if ($existing instanceof File && !$this->folders->isLibraryPointer($existing)) {
+ throw new InvalidArgumentException('A canvas template named "' . $name . '" already exists. Choose a different name.', Http::STATUS_CONFLICT);
+ }
+ }
+
+ $folder = $scope === self::SCOPE_ORG
+ ? $this->getOrgLibraryFolder(true)
+ : $this->getPersonalLibraryFolder($uid, true);
+ if (!$folder instanceof Folder) {
+ throw new NotFoundException('Library folder not available');
+ }
+
+ $this->writeLibraryFile($folder, $name . self::LIBRARY_EXTENSION, $sanitized);
+ $this->writeLibraryPointer($pointerFolder, $scope, $name);
+
+ return ['name' => $name, 'scope' => $scope];
+ }
+
+ /**
+ * @throws NotPermittedException
+ * @throws NotFoundException
+ */
+ public function deleteLibrary(string $uid, string $scope, string $name): void {
+ $scope = $this->folders->normalizeScope($scope);
+ $name = $this->folders->normalizeName($name);
+
+ $folder = $scope === self::SCOPE_ORG
+ ? $this->getOrgLibraryFolder(false)
+ : $this->getPersonalLibraryFolder($uid);
+ $this->folders->deleteIfExists($folder, $name . self::LIBRARY_EXTENSION);
+
+ $pointerFolder = $scope === self::SCOPE_ORG
+ ? $this->getOrgLibraryPointerFolder(false)
+ : ($this->folders->hasTemplateDirectory() ? $this->folders->getUserTemplateFolder($uid) : null);
+ $pointerName = $name . self::WHITEBOARD_EXTENSION;
+ if ($pointerFolder instanceof Folder && $pointerFolder->nodeExists($pointerName)) {
+ $node = $pointerFolder->get($pointerName);
+ // A personal canvas template shares the pointer's path
+ // (Templates/.whiteboard) — only delete actual pointers.
+ if ($node instanceof File && $this->folders->isLibraryPointer($node)) {
+ $node->delete();
+ }
+ }
+ }
+
+ /**
+ * Pointer board files for the organization picker entries.
+ *
+ * @return array
+ */
+ public function getOrgLibraryPointerFiles(): array {
+ $folder = $this->getOrgLibraryPointerFolder(false);
+ if (!$folder instanceof Folder) {
+ return [];
+ }
+ $files = [];
+ foreach ($folder->getDirectoryListing() as $node) {
+ if ($node instanceof File && $this->isWhiteboardFile($node->getName())) {
+ $files[] = $node;
+ }
+ }
+ return $files;
+ }
+
+ public function libraryNameFromPointer(string $fileName): string {
+ return $this->isWhiteboardFile($fileName)
+ ? substr($fileName, 0, -strlen(self::WHITEBOARD_EXTENSION))
+ : $fileName;
+ }
+
+ /**
+ * Parse an uploaded .excalidrawlib file (v1 or v2) into library items.
+ *
+ * @return array
+ */
+ public function parseLibraryFile(string $content): array {
+ try {
+ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException) {
+ throw new InvalidArgumentException('This is not a valid library file.', Http::STATUS_BAD_REQUEST);
+ }
+ if (!is_array($data)) {
+ throw new InvalidArgumentException('This is not a valid library file.', Http::STATUS_BAD_REQUEST);
+ }
+
+ $items = [];
+ if (isset($data['libraryItems']) && is_array($data['libraryItems'])) {
+ $items = $data['libraryItems'];
+ } elseif (isset($data['library']) && is_array($data['library'])) {
+ foreach ($data['library'] as $elements) {
+ if (is_array($elements)) {
+ $items[] = ['elements' => $elements];
+ }
+ }
+ }
+
+ $sanitized = $this->sanitizeLibraryItems($items);
+ if (count($sanitized) === 0) {
+ throw new InvalidArgumentException('This library file has no items.', Http::STATUS_BAD_REQUEST);
+ }
+ if ($this->containsImageElement($sanitized)) {
+ throw new InvalidArgumentException('This library contains image items that cannot be imported by Whiteboard yet.', Http::STATUS_BAD_REQUEST);
+ }
+ return $sanitized;
+ }
+
+ // ---------------------------------------------------------------------
+ // internals
+ // ---------------------------------------------------------------------
+
+ /**
+ * One-time merge of pre-1.6 library files into "My library". Older
+ * releases read every *.excalidrawlib in the Templates folder (the manual
+ * import flow for excalidraw.com kits); the new model only reads
+ * personal.excalidrawlib, so without this their items would silently
+ * disappear from the palette. The legacy files are left in place — only
+ * the migrated flag stops them from being merged again.
+ */
+ private function migrateLegacyLibraries(string $uid, Folder $templatesFolder): void {
+ if ($this->config->getUserValue($uid, 'whiteboard', self::LEGACY_MIGRATED_FLAG, '') === '1') {
+ return;
+ }
+
+ $legacyItems = [];
+ foreach ($templatesFolder->getDirectoryListing() as $node) {
+ if (!$node instanceof File
+ || !$this->isLibraryFile($node->getName())
+ || $node->getName() === self::PERSONAL_FILE) {
+ continue;
+ }
+ $lib = $this->decodeLibrary($node);
+ if ($lib === null) {
+ continue;
+ }
+ $items = [];
+ if (isset($lib['libraryItems']) && is_array($lib['libraryItems'])) {
+ $items = $lib['libraryItems'];
+ } elseif (isset($lib['library']) && is_array($lib['library'])) {
+ // v1 files are bare element lists without item metadata.
+ foreach ($lib['library'] as $elements) {
+ if (is_array($elements)) {
+ $items[] = ['elements' => $elements];
+ }
+ }
+ }
+ $legacyItems = array_merge($legacyItems, $this->sanitizeLibraryItems($items));
+ }
+
+ if ($legacyItems !== []) {
+ $existing = [];
+ if ($templatesFolder->nodeExists(self::PERSONAL_FILE)) {
+ $file = $templatesFolder->get(self::PERSONAL_FILE);
+ if ($file instanceof File) {
+ $lib = $this->decodeLibrary($file);
+ if (is_array($lib) && isset($lib['libraryItems']) && is_array($lib['libraryItems'])) {
+ $existing = $this->sanitizeLibraryItems($lib['libraryItems']);
+ }
+ }
+ }
+ $existingIds = [];
+ foreach ($existing as $item) {
+ if (isset($item['id']) && is_string($item['id'])) {
+ $existingIds[$item['id']] = true;
}
}
+ foreach ($legacyItems as $item) {
+ if (isset($item['id']) && is_string($item['id']) && isset($existingIds[$item['id']])) {
+ continue;
+ }
+ $existing[] = $item;
+ }
+ $this->writeLibraryFile($templatesFolder, self::PERSONAL_FILE, $existing);
+ $this->logger->info('Merged legacy whiteboard library files into personal library', [
+ 'app' => 'whiteboard',
+ 'itemCount' => count($legacyItems),
+ ]);
+ }
+
+ $this->config->setUserValue($uid, 'whiteboard', self::LEGACY_MIGRATED_FLAG, '1');
+ }
+
+ private function decodeLibrary(File $file): ?array {
+ try {
+ $lib = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException) {
+ $this->logger->warning('Skipping malformed whiteboard library', [
+ 'app' => 'whiteboard',
+ 'file' => $file->getName(),
+ ]);
+ return null;
+ }
+ return is_array($lib) ? $lib : null;
+ }
+
+ /**
+ * @param array $items
+ *
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ */
+ private function writeLibraryFile(Folder $folder, string $fileName, array $items): void {
+ $payload = ['type' => 'excalidrawlib', 'version' => 2, 'libraryItems' => array_values($items)];
+ $file = $folder->nodeExists($fileName) ? $folder->get($fileName) : $folder->newFile($fileName);
+ if (!$file instanceof File) {
+ throw new GenericFileException('Failed to create or get file: ' . $fileName);
+ }
+ $file->putContent(json_encode($payload, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
+ }
+
+ /**
+ * Write the pointer ".whiteboard" file that surfaces a library in the picker.
+ *
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ */
+ private function writeLibraryPointer(Folder $folder, string $scope, string $name): void {
+ $content = json_encode([
+ 'elements' => [],
+ 'files' => [],
+ 'scrollToContent' => true,
+ 'libraryRef' => ['scope' => $scope, 'name' => $name],
+ ], JSON_THROW_ON_ERROR);
+
+ $fileName = $name . self::WHITEBOARD_EXTENSION;
+ $file = $folder->nodeExists($fileName) ? $folder->get($fileName) : $folder->newFile($fileName);
+ if (!$file instanceof File) {
+ throw new GenericFileException('Failed to create or get pointer: ' . $fileName);
+ }
+ $file->putContent($content);
+ }
+
+ /**
+ * @return array
+ */
+ private function listLibrariesIn(?Folder $folder): array {
+ if (!$folder instanceof Folder) {
+ return [];
+ }
+ $libraries = [];
+ foreach ($folder->getDirectoryListing() as $node) {
+ if (!$node instanceof File || !$this->isLibraryFile($node->getName())) {
+ continue;
+ }
+ $lib = $this->decodeLibrary($node);
+ $itemCount = is_array($lib) && isset($lib['libraryItems']) && is_array($lib['libraryItems']) ? count($lib['libraryItems']) : 0;
+ $libraries[] = [
+ 'name' => substr($node->getName(), 0, -strlen(self::LIBRARY_EXTENSION)),
+ 'itemCount' => $itemCount,
+ ];
}
+ usort($libraries, static fn (array $a, array $b): int => strcasecmp($a['name'], $b['name']));
+ return $libraries;
+ }
- foreach ($files as $filename => $fileData) {
- if ($templatesFolder->nodeExists($filename)) {
- $file = $templatesFolder->get($filename);
- } else {
- $file = $templatesFolder->newFile($filename);
+ /**
+ * Returns null when the folder does not exist yet and $create is false,
+ * so list/resolve/delete on a fresh account yield empty results, not 404s.
+ *
+ * @throws NotPermittedException
+ * @throws NotFoundException
+ */
+ private function getPersonalLibraryFolder(string $uid, bool $create = false): ?Folder {
+ $templates = $this->folders->getUserTemplateFolder($uid);
+ if (!$templates->nodeExists(self::PERSONAL_LIBRARY_DIR)) {
+ if (!$create) {
+ return null;
}
+ $templates->newFolder(self::PERSONAL_LIBRARY_DIR);
+ }
+ $node = $templates->get(self::PERSONAL_LIBRARY_DIR);
+ if (!$node instanceof Folder) {
+ throw new RuntimeException('Expected folder ' . self::PERSONAL_LIBRARY_DIR);
+ }
+ return $node;
+ }
+
+ private function getOrgLibraryFolder(bool $create): ?Folder {
+ return $this->folders->getAppDataFolder(self::ORG_LIBRARY_DIR, $create);
+ }
+
+ private function getOrgLibraryPointerFolder(bool $create): ?Folder {
+ return $this->folders->getAppDataFolder(self::ORG_LIBRARY_POINTER_DIR, $create);
+ }
- if (!$file instanceof File) {
- throw new GenericFileException('Failed to create or get file: ' . $filename);
+ /**
+ * @param array $items
+ *
+ * @return array
+ */
+ private function sanitizeLibraryItems(array $items): array {
+ $sanitized = [];
+ foreach ($items as $item) {
+ if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements']) || count($item['elements']) === 0) {
+ continue;
}
+ unset($item['filename'], $item['basename'], $item['name'], $item['writable'], $item['libraryName']);
+ $item['elements'] = array_values($item['elements']);
+ $sanitized[] = $item;
+ }
+ return $sanitized;
+ }
- $file->putContent(json_encode($fileData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
+ /**
+ * Library files carry no binary file data, so image elements would render
+ * as permanently broken placeholders once resolved into a board.
+ *
+ * @param array $items
+ */
+ private function containsImageElement(array $items): bool {
+ foreach ($items as $item) {
+ if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements'])) {
+ continue;
+ }
+ foreach ($item['elements'] as $element) {
+ if (is_array($element) && ($element['type'] ?? null) === 'image') {
+ return true;
+ }
+ }
}
+ return false;
+ }
+
+ private function isLibraryFile(string $fileName): bool {
+ return str_ends_with(strtolower($fileName), self::LIBRARY_EXTENSION);
+ }
+
+ private function isWhiteboardFile(string $fileName): bool {
+ return str_ends_with(strtolower($fileName), self::WHITEBOARD_EXTENSION);
}
}
diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php
index c386b7d4..50caba18 100644
--- a/lib/Settings/Admin.php
+++ b/lib/Settings/Admin.php
@@ -13,6 +13,7 @@
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Settings\ISettings;
+use OCP\Util;
class Admin implements ISettings {
public function __construct(
@@ -28,6 +29,9 @@ public function getForm(): TemplateResponse {
$this->initialState->provideInitialState('secret', $this->configService->getWhiteboardSharedSecret());
$this->initialState->provideInitialState('jwt', $this->jwtService->generateJWTFromPayload([]));
$this->initialState->provideInitialState('maxFileSize', $this->configService->getMaxFileSize());
+ // Org templates surface in the picker via the template provider API,
+ // which only exists on Nextcloud 30+ (see AppInfo\Application).
+ $this->initialState->provideInitialState('orgTemplatesSupported', Util::getVersion()[0] >= 30);
$response = new TemplateResponse(
'whiteboard',
'admin',
diff --git a/lib/Template/GlobalTemplateProvider.php b/lib/Template/GlobalTemplateProvider.php
new file mode 100644
index 00000000..a57d2fd5
--- /dev/null
+++ b/lib/Template/GlobalTemplateProvider.php
@@ -0,0 +1,105 @@
+ new Template(
+ self::class,
+ self::LIBRARY_ID_PREFIX . $this->libraryService->libraryNameFromPointer($file->getName()),
+ $file
+ ),
+ $this->libraryService->getOrgLibraryPointerFiles()
+ );
+ $templates = array_map(
+ fn (File $file): Template => new Template(
+ self::class,
+ self::CANVAS_TEMPLATE_ID_PREFIX . $this->canvasTemplateService->canvasTemplateNameFromFile($file->getName()),
+ $file
+ ),
+ $this->canvasTemplateService->getOrgCanvasTemplateFiles()
+ );
+ return array_merge($templates, $libraries);
+ } catch (Exception $e) {
+ $this->logger->warning('Failed to list organization whiteboard canvas templates', ['exception' => $e]);
+ return [];
+ }
+ }
+
+ /**
+ * @throws NotFoundException
+ */
+ #[\Override]
+ public function getCustomTemplate(string $template): File {
+ if (str_starts_with($template, self::CANVAS_TEMPLATE_ID_PREFIX)) {
+ $name = substr($template, strlen(self::CANVAS_TEMPLATE_ID_PREFIX));
+ foreach ($this->canvasTemplateService->getOrgCanvasTemplateFiles() as $file) {
+ if ($this->canvasTemplateService->canvasTemplateNameFromFile($file->getName()) === $name) {
+ return $file;
+ }
+ }
+ throw new NotFoundException('Organization canvas template not found');
+ }
+
+ $name = str_starts_with($template, self::LIBRARY_ID_PREFIX)
+ ? substr($template, strlen(self::LIBRARY_ID_PREFIX))
+ : $template;
+
+ foreach ($this->libraryService->getOrgLibraryPointerFiles() as $file) {
+ if ($this->libraryService->libraryNameFromPointer($file->getName()) === $name) {
+ return $file;
+ }
+ }
+
+ throw new NotFoundException('Organization library not found');
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index dbfde122..93b80502 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,7 @@
"@nextcloud/capabilities": "^1.2.1",
"@nextcloud/dialogs": "^6.4.1",
"@nextcloud/event-bus": "^3.3.3",
- "@nextcloud/excalidraw": "0.18.0-6135eb0",
+ "@nextcloud/excalidraw": "0.18.0-5b75b44",
"@nextcloud/files": "^4.0.0",
"@nextcloud/initial-state": "^3.0.0",
"@nextcloud/l10n": "^3.4.1",
@@ -415,6 +415,8 @@
},
"node_modules/@braintree/sanitize-url": {
"version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz",
+ "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==",
"license": "MIT"
},
"node_modules/@buttercup/fetch": {
@@ -1125,6 +1127,8 @@
},
"node_modules/@excalidraw/laser-pointer": {
"version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz",
+ "integrity": "sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==",
"license": "MIT"
},
"node_modules/@excalidraw/markdown-to-text": {
@@ -1161,6 +1165,8 @@
},
"node_modules/@excalidraw/random-username": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@excalidraw/random-username/-/random-username-1.1.0.tgz",
+ "integrity": "sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==",
"license": "MIT",
"engines": {
"node": ">=10"
@@ -1175,25 +1181,31 @@
}
},
"node_modules/@floating-ui/core": {
- "version": "1.7.3",
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
+ "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
- "@floating-ui/utils": "^0.2.10"
+ "@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
- "version": "1.7.4",
+ "version": "1.7.6",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
+ "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
- "@floating-ui/core": "^1.7.3",
- "@floating-ui/utils": "^0.2.10"
+ "@floating-ui/core": "^1.7.5",
+ "@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react-dom": {
- "version": "2.1.4",
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
+ "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
"license": "MIT",
"dependencies": {
- "@floating-ui/dom": "^1.7.2"
+ "@floating-ui/dom": "^1.7.6"
},
"peerDependencies": {
"react": ">=16.8.0",
@@ -1201,7 +1213,9 @@
}
},
"node_modules/@floating-ui/utils": {
- "version": "0.2.10",
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
+ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@grpc/grpc-js": {
@@ -2070,9 +2084,9 @@
}
},
"node_modules/@nextcloud/excalidraw": {
- "version": "0.18.0-6135eb0",
- "resolved": "https://registry.npmjs.org/@nextcloud/excalidraw/-/excalidraw-0.18.0-6135eb0.tgz",
- "integrity": "sha512-+yqCaBX/b5UBALS04qknYoDaEvV3PlNGwzYZ2HmRlRDJTaLRGZYgWbR6KbYGr8ogghhzQZELorMKKyAiilyNRw==",
+ "version": "0.18.0-5b75b44",
+ "resolved": "https://registry.npmjs.org/@nextcloud/excalidraw/-/excalidraw-0.18.0-5b75b44.tgz",
+ "integrity": "sha512-eHJeRn6UhxM+idT3Zsn1sKMISrFEqImmIejOytFdowxvTWiG8r22PMVJ0t3kET0d+Ekq5j9phLHL8i+8TzVQ3A==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
@@ -2114,6 +2128,9 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/@excalidraw/mermaid-to-excalidraw": {
"version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.2.tgz",
+ "integrity": "sha512-hAFv/TTIsOdoy0dL5v+oBd297SQ+Z88gZ5u99fCIFuEMHfQuPgLhU/ztKhFSTs7fISwVo6fizny/5oQRR3d4tQ==",
+ "license": "MIT",
"dependencies": {
"@excalidraw/markdown-to-text": "0.1.2",
"mermaid": "10.9.3",
@@ -2122,6 +2139,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/nanoid": {
"version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz",
+ "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==",
"funding": [
{
"type": "github",
@@ -2138,6 +2157,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/@types/mdast": {
"version": "3.0.15",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz",
+ "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2"
@@ -2145,10 +2166,14 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/@types/unist": {
"version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/@nextcloud/excalidraw/node_modules/dompurify": {
"version": "3.1.6",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz",
+ "integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==",
"license": "(MPL-2.0 OR Apache-2.0)"
},
"node_modules/@nextcloud/excalidraw/node_modules/immutable": {
@@ -2159,6 +2184,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/mdast-util-from-markdown": {
"version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz",
+ "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^3.0.0",
@@ -2181,6 +2208,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/mdast-util-to-string": {
"version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz",
+ "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^3.0.0"
@@ -2192,6 +2221,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/mermaid": {
"version": "10.9.3",
+ "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-10.9.3.tgz",
+ "integrity": "sha512-V80X1isSEvAewIL3xhmz/rVmc27CVljcsbWxkxlWJWY/1kQa4XOABqpDl2qQLGKzpKm6WbTfUEKImBlUfFYArw==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^6.0.1",
@@ -2218,6 +2249,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark": {
"version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz",
+ "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2251,6 +2284,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-core-commonmark": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz",
+ "integrity": "sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2283,6 +2318,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-factory-destination": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz",
+ "integrity": "sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2302,6 +2339,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-factory-label": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz",
+ "integrity": "sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2322,6 +2361,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-factory-space": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz",
+ "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2340,6 +2381,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-factory-title": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz",
+ "integrity": "sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2360,6 +2403,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-factory-whitespace": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz",
+ "integrity": "sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2380,6 +2425,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-util-character": {
"version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz",
+ "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2398,6 +2445,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-util-chunked": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz",
+ "integrity": "sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2415,6 +2464,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-util-classify-character": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz",
+ "integrity": "sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2434,6 +2485,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-util-combine-extensions": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz",
+ "integrity": "sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2452,6 +2505,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-util-decode-numeric-character-reference": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz",
+ "integrity": "sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2469,6 +2524,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-util-decode-string": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz",
+ "integrity": "sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2489,6 +2546,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-util-encode": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz",
+ "integrity": "sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2503,6 +2562,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-util-html-tag-name": {
"version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz",
+ "integrity": "sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2517,6 +2578,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-util-normalize-identifier": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz",
+ "integrity": "sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2534,6 +2597,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-util-resolve-all": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz",
+ "integrity": "sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2551,6 +2616,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-util-sanitize-uri": {
"version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz",
+ "integrity": "sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2570,6 +2637,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-util-subtokenize": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz",
+ "integrity": "sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2590,6 +2659,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-util-symbol": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz",
+ "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2604,6 +2675,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/micromark-util-types": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz",
+ "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -2618,6 +2691,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/nanoid": {
"version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz",
+ "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==",
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
@@ -2628,10 +2703,14 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/pako": {
"version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.3.tgz",
+ "integrity": "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==",
"license": "(MIT AND Zlib)"
},
"node_modules/@nextcloud/excalidraw/node_modules/sass": {
"version": "1.51.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.51.0.tgz",
+ "integrity": "sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==",
"license": "MIT",
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
@@ -2647,6 +2726,8 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/unist-util-stringify-position": {
"version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz",
+ "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2.0.0"
@@ -2658,6 +2739,9 @@
},
"node_modules/@nextcloud/excalidraw/node_modules/uuid": {
"version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
@@ -3477,10 +3561,14 @@
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
+ "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz",
+ "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.2"
@@ -3502,6 +3590,8 @@
},
"node_modules/@radix-ui/react-collection": {
"version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.1.tgz",
+ "integrity": "sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
@@ -3517,6 +3607,8 @@
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-compose-refs": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz",
+ "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
@@ -3527,6 +3619,8 @@
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz",
+ "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
@@ -3537,6 +3631,8 @@
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": {
"version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz",
+ "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
@@ -3549,6 +3645,8 @@
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz",
+ "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
@@ -3560,6 +3658,8 @@
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
+ "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
@@ -3573,6 +3673,8 @@
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
+ "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
@@ -3586,6 +3688,8 @@
},
"node_modules/@radix-ui/react-direction": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.0.tgz",
+ "integrity": "sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
@@ -3596,6 +3700,8 @@
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz",
+ "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
@@ -3621,6 +3727,8 @@
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz",
+ "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
@@ -3634,6 +3742,8 @@
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz",
+ "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
@@ -3657,6 +3767,8 @@
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz",
+ "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.0"
@@ -3673,6 +3785,8 @@
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz",
+ "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
@@ -3708,6 +3822,8 @@
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
+ "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
@@ -3738,6 +3854,8 @@
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
+ "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.2",
@@ -3760,6 +3878,8 @@
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
+ "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
@@ -3782,6 +3902,8 @@
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
+ "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.2"
@@ -3803,6 +3925,8 @@
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.2.tgz",
+ "integrity": "sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
@@ -3823,6 +3947,8 @@
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz",
+ "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
@@ -3830,6 +3956,8 @@
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz",
+ "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
@@ -3840,6 +3968,8 @@
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz",
+ "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
@@ -3850,6 +3980,8 @@
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-id": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz",
+ "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
@@ -3861,6 +3993,8 @@
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": {
"version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz",
+ "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
@@ -3873,6 +4007,8 @@
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": {
"version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz",
+ "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
@@ -3884,6 +4020,8 @@
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz",
+ "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
@@ -3894,6 +4032,8 @@
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz",
+ "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
@@ -3905,6 +4045,8 @@
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz",
+ "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
@@ -3915,6 +4057,8 @@
},
"node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
+ "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
@@ -3931,6 +4075,8 @@
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.2.tgz",
+ "integrity": "sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
@@ -3950,6 +4096,8 @@
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz",
+ "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
@@ -3957,6 +4105,8 @@
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz",
+ "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
@@ -3967,6 +4117,8 @@
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz",
+ "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
@@ -3977,6 +4129,8 @@
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz",
+ "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
@@ -3988,6 +4142,8 @@
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.0.tgz",
+ "integrity": "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
@@ -4001,6 +4157,8 @@
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": {
"version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz",
+ "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
@@ -4013,6 +4171,8 @@
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": {
"version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz",
+ "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
@@ -4024,6 +4184,8 @@
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz",
+ "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
@@ -4034,6 +4196,8 @@
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz",
+ "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
@@ -4045,6 +4209,8 @@
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz",
+ "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
@@ -4055,6 +4221,8 @@
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
+ "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
@@ -4068,6 +4236,8 @@
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz",
+ "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.0"
@@ -4084,6 +4254,8 @@
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
+ "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.0"
@@ -4100,6 +4272,8 @@
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz",
+ "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
@@ -4113,6 +4287,8 @@
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz",
+ "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.0"
@@ -4129,6 +4305,8 @@
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz",
+ "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.0"
@@ -4145,6 +4323,8 @@
},
"node_modules/@radix-ui/rect": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz",
+ "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==",
"license": "MIT"
},
"node_modules/@redis/bloom": {
@@ -6438,6 +6618,8 @@
},
"node_modules/aria-hidden": {
"version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
@@ -6890,6 +7072,8 @@
},
"node_modules/browser-fs-access": {
"version": "0.29.1",
+ "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.29.1.tgz",
+ "integrity": "sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==",
"license": "Apache-2.0"
},
"node_modules/browser-resolve": {
@@ -7276,6 +7460,8 @@
},
"node_modules/canvas-roundrect-polyfill": {
"version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/canvas-roundrect-polyfill/-/canvas-roundrect-polyfill-0.0.1.tgz",
+ "integrity": "sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==",
"license": "MIT"
},
"node_modules/chai": {
@@ -7442,6 +7628,8 @@
},
"node_modules/clsx": {
"version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz",
+ "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==",
"license": "MIT",
"engines": {
"node": ">=6"
@@ -7676,6 +7864,8 @@
},
"node_modules/crc-32": {
"version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-0.3.0.tgz",
+ "integrity": "sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
@@ -7727,6 +7917,8 @@
},
"node_modules/cross-env": {
"version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
+ "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
@@ -8236,6 +8428,8 @@
},
"node_modules/dagre-d3-es": {
"version": "7.0.10",
+ "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz",
+ "integrity": "sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A==",
"license": "MIT",
"dependencies": {
"d3": "^7.8.2",
@@ -8451,6 +8645,8 @@
},
"node_modules/detect-node-es": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/devlop": {
@@ -8700,6 +8896,8 @@
},
"node_modules/elkjs": {
"version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz",
+ "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==",
"license": "EPL-2.0"
},
"node_modules/elliptic": {
@@ -9031,6 +9229,8 @@
},
"node_modules/es6-promise-pool": {
"version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz",
+ "integrity": "sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -10376,6 +10576,8 @@
},
"node_modules/fractional-indexing": {
"version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.2.0.tgz",
+ "integrity": "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==",
"license": "CC0-1.0",
"engines": {
"node": "^14.13.1 || >=16.0.0"
@@ -10462,6 +10664,8 @@
},
"node_modules/fuzzy": {
"version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/fuzzy/-/fuzzy-0.1.3.tgz",
+ "integrity": "sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==",
"engines": {
"node": ">= 0.6.0"
}
@@ -10511,6 +10715,8 @@
},
"node_modules/get-nonce": {
"version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
@@ -10722,6 +10928,8 @@
},
"node_modules/glur": {
"version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/glur/-/glur-1.1.2.tgz",
+ "integrity": "sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==",
"license": "MIT"
},
"node_modules/gopd": {
@@ -11076,6 +11284,8 @@
},
"node_modules/image-blob-reduce": {
"version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/image-blob-reduce/-/image-blob-reduce-3.0.1.tgz",
+ "integrity": "sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==",
"license": "MIT",
"dependencies": {
"pica": "^7.1.0"
@@ -11752,6 +11962,8 @@
},
"node_modules/jotai": {
"version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz",
+ "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
@@ -11771,6 +11983,8 @@
},
"node_modules/jotai-scope": {
"version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/jotai-scope/-/jotai-scope-0.7.2.tgz",
+ "integrity": "sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==",
"license": "MIT",
"peerDependencies": {
"jotai": ">=2.9.2",
@@ -11962,6 +12176,8 @@
},
"node_modules/kleur": {
"version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
"license": "MIT",
"engines": {
"node": ">=6"
@@ -12136,6 +12352,8 @@
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.includes": {
@@ -13113,6 +13331,8 @@
},
"node_modules/mri": {
"version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"license": "MIT",
"engines": {
"node": ">=4"
@@ -13129,6 +13349,8 @@
},
"node_modules/multimath": {
"version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/multimath/-/multimath-2.0.0.tgz",
+ "integrity": "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==",
"license": "MIT",
"dependencies": {
"glur": "^1.1.2",
@@ -13370,6 +13592,8 @@
},
"node_modules/non-layered-tidy-tree-layout": {
"version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz",
+ "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==",
"license": "MIT"
},
"node_modules/normalize-path": {
@@ -13530,6 +13754,8 @@
},
"node_modules/open-color": {
"version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/open-color/-/open-color-1.9.1.tgz",
+ "integrity": "sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==",
"license": "MIT"
},
"node_modules/optimist": {
@@ -13906,6 +14132,8 @@
},
"node_modules/perfect-freehand": {
"version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.0.tgz",
+ "integrity": "sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==",
"license": "MIT"
},
"node_modules/persist": {
@@ -13923,6 +14151,8 @@
},
"node_modules/pica": {
"version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/pica/-/pica-7.1.1.tgz",
+ "integrity": "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==",
"license": "MIT",
"dependencies": {
"glur": "^1.1.2",
@@ -14028,10 +14258,14 @@
},
"node_modules/png-chunk-text": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/png-chunk-text/-/png-chunk-text-1.0.0.tgz",
+ "integrity": "sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==",
"license": "MIT"
},
"node_modules/png-chunks-encode": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/png-chunks-encode/-/png-chunks-encode-1.0.0.tgz",
+ "integrity": "sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==",
"license": "MIT",
"dependencies": {
"crc-32": "^0.3.0",
@@ -14040,6 +14274,8 @@
},
"node_modules/png-chunks-extract": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/png-chunks-extract/-/png-chunks-extract-1.0.0.tgz",
+ "integrity": "sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==",
"license": "MIT",
"dependencies": {
"crc-32": "^0.3.0"
@@ -14047,6 +14283,8 @@
},
"node_modules/points-on-curve": {
"version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-1.0.1.tgz",
+ "integrity": "sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==",
"license": "MIT"
},
"node_modules/points-on-path": {
@@ -14477,6 +14715,8 @@
},
"node_modules/pwacompat": {
"version": "2.0.17",
+ "resolved": "https://registry.npmjs.org/pwacompat/-/pwacompat-2.0.17.tgz",
+ "integrity": "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==",
"license": "Apache-2.0"
},
"node_modules/qs": {
@@ -14629,7 +14869,9 @@
}
},
"node_modules/react-remove-scroll": {
- "version": "2.7.1",
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+ "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
@@ -14653,6 +14895,8 @@
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
@@ -14673,6 +14917,8 @@
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
@@ -15294,6 +15540,8 @@
},
"node_modules/roughjs": {
"version": "4.6.4",
+ "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.4.tgz",
+ "integrity": "sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==",
"license": "MIT",
"dependencies": {
"hachure-fill": "^0.5.2",
@@ -15304,6 +15552,8 @@
},
"node_modules/roughjs/node_modules/points-on-curve": {
"version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
+ "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==",
"license": "MIT"
},
"node_modules/router": {
@@ -15357,6 +15607,8 @@
},
"node_modules/sade": {
"version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
+ "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"license": "MIT",
"dependencies": {
"mri": "^1.1.0"
@@ -15761,6 +16013,9 @@
},
"node_modules/sliced": {
"version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz",
+ "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==",
+ "deprecated": "Unsupported",
"license": "MIT"
},
"node_modules/socket.io": {
@@ -17049,6 +17304,8 @@
},
"node_modules/tunnel-rat": {
"version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
+ "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==",
"license": "MIT",
"dependencies": {
"zustand": "^4.3.2"
@@ -17056,6 +17313,8 @@
},
"node_modules/tunnel-rat/node_modules/zustand": {
"version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
@@ -17524,6 +17783,8 @@
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
@@ -17543,6 +17804,8 @@
},
"node_modules/use-sidecar": {
"version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
@@ -17562,7 +17825,9 @@
}
},
"node_modules/use-sync-external-store": {
- "version": "1.5.0",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -17600,6 +17865,8 @@
},
"node_modules/uvu": {
"version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz",
+ "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.0",
@@ -18094,6 +18361,8 @@
},
"node_modules/web-worker": {
"version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
+ "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
"license": "Apache-2.0"
},
"node_modules/webdav": {
@@ -18173,6 +18442,8 @@
},
"node_modules/webworkify": {
"version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/webworkify/-/webworkify-1.5.0.tgz",
+ "integrity": "sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==",
"license": "MIT"
},
"node_modules/which": {
diff --git a/package.json b/package.json
index 48b14f88..861544de 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
"@nextcloud/capabilities": "^1.2.1",
"@nextcloud/dialogs": "^6.4.1",
"@nextcloud/event-bus": "^3.3.3",
- "@nextcloud/excalidraw": "0.18.0-6135eb0",
+ "@nextcloud/excalidraw": "0.18.0-5b75b44",
"@nextcloud/files": "^4.0.0",
"@nextcloud/initial-state": "^3.0.0",
"@nextcloud/l10n": "^3.4.1",
diff --git a/src/App.tsx b/src/App.tsx
index 73b67377..0a1477ea 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,13 +5,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
-import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
+import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { getCurrentUser } from '@nextcloud/auth'
import { translate as t } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'
import { Excalidraw as ExcalidrawComponent, useHandleLibrary, Sidebar, isElementLink } from '@nextcloud/excalidraw'
import '@excalidraw/excalidraw/index.css'
-import type { LibraryItems } from '@nextcloud/excalidraw/dist/types/excalidraw/types'
+import type { ExcalidrawImperativeAPI, LibraryItems } from '@nextcloud/excalidraw/dist/types/excalidraw/types'
import { useExcalidrawStore } from './stores/useExcalidrawStore'
import { useWhiteboardConfigStore } from './stores/useWhiteboardConfigStore'
import { useThemeHandling } from './hooks/useThemeHandling'
@@ -27,7 +27,12 @@ import { AuthErrorNotification } from './components/AuthErrorNotification'
import { useSync } from './hooks/useSync'
import { useSyncStore } from './stores/useSyncStore'
import { useLibrary } from './hooks/useLibrary'
+import { useLibraryCatalog } from './hooks/useLibraryCatalog'
+import { useCanvasTemplate } from './hooks/useCanvasTemplate'
+import { SaveScopedDialog } from './components/SaveScopedDialog'
+import type { SaveScope } from './components/SaveScopedDialog'
import { useShallow } from 'zustand/react/shallow'
+import { showError, showSuccess } from '@nextcloud/dialogs'
import { useBoardDataManager } from './hooks/useBoardDataManager'
import { useAssistant } from './hooks/useAssistant'
import logger from './utils/logger'
@@ -132,6 +137,18 @@ export default function App({
const { renderEmojiPicker } = useEmojiPicker()
const { onChange: onChangeSync, onPointerUpdate } = useSync()
const { fetchLibraryItems, updateLibraryItems, isLibraryLoaded, setIsLibraryLoaded } = useLibrary()
+ const { resolveLibrary, saveLibrary } = useLibraryCatalog()
+ const { publishCanvasTemplate } = useCanvasTemplate()
+ // Org saves need admin rights and a server (30+) whose picker can
+ // actually surface org templates.
+ const canSaveOrg = useMemo(() => loadState('whiteboard', 'isAdmin', false)
+ && loadState('whiteboard', 'orgTemplatesSupported', true), [])
+ const [libraryDialogOpen, setLibraryDialogOpen] = useState(false)
+ const [canvasTemplateDialogOpen, setCanvasTemplateDialogOpen] = useState(false)
+ const [saveDialogError, setSaveDialogError] = useState(null)
+ const [isSavingDialog, setIsSavingDialog] = useState(false)
+ // Items captured when "Save as library" is triggered from the library ⋯ menu.
+ const libraryItemsRef = useRef([])
useCollaboration()
const { isReadOnly, refreshReadOnlyState } = useReadOnlyState()
@@ -286,7 +303,10 @@ export default function App({
}, [handleExternalRestore, normalizedFileId])
// Use the board data manager hook
- const { saveOnUnmount, isLoading } = useBoardDataManager()
+ const {
+ saveOnUnmount,
+ isLoading,
+ } = useBoardDataManager()
useEffect(() => {
if (!isLoading && loadState('whiteboard', 'directEditing', false)) {
@@ -296,6 +316,8 @@ export default function App({
// Effect to handle fileId changes - cleanup previous board data
useEffect(() => {
+ setIsLibraryLoaded(false)
+
// Clear any existing Excalidraw data when fileId changes
if (excalidrawAPI) {
excalidrawAPI.resetScene()
@@ -310,32 +332,62 @@ export default function App({
saveOnUnmount()
}
}
- }, [normalizedFileId, excalidrawAPI, resetInitialDataPromise, saveOnUnmount])
+ }, [normalizedFileId, excalidrawAPI, resetInitialDataPromise, saveOnUnmount, setIsLibraryLoaded])
useEffect(() => {
- resetInitialDataPromise()
+ if (isLoading || !excalidrawAPI) {
+ return
+ }
// Fetch library items from the API
window.name = fileName
- const fetchLibInterval = setInterval(async () => {
- const api = useExcalidrawStore.getState().excalidrawAPI
- if (!api) {
- logger.warn('[App] Excalidraw API not available, cannot update library')
- return
- }
- clearInterval(fetchLibInterval)
+ setIsLibraryLoaded(false)
+ let cancelled = false
+ ;(async () => {
try {
- const libraryItems = await fetchLibraryItems()
- await api.updateLibrary({
- libraryItems: libraryItems || [],
+ // Authoritative palette (merge:false): the writable "My library"
+ // (untagged) plus, if this board was created from a saved library,
+ // that library's CURRENT items resolved live (tagged libraryName ->
+ // rendered as a read-only section, never written back into My library).
+ const myLibrary = (await fetchLibraryItems()) ?? []
+ const ref = useWhiteboardConfigStore.getState().libraryRef
+ let refItems: LibraryItems = []
+ if (ref) {
+ try {
+ refItems = await resolveLibrary(ref.scope, ref.name)
+ } catch (error) {
+ // Board still opens with the personal library only.
+ showError(t('whiteboard', 'Could not load the library "{name}".', { name: ref.name }))
+ logger.error('[App] Error resolving library reference:', error)
+ }
+ }
+ if (cancelled) {
+ return
+ }
+ await excalidrawAPI.updateLibrary({
+ libraryItems: [...myLibrary, ...refItems],
+ merge: false,
})
setIsLibraryLoaded(true)
} catch (error) {
logger.error('[App] Error updating library items:', error)
}
- }, 1000)
+ })()
- // On unmount: Clean up all stores to prevent stale state
+ return () => {
+ cancelled = true
+ }
+ }, [
+ fileName,
+ fetchLibraryItems,
+ resolveLibrary,
+ isLoading,
+ excalidrawAPI,
+ normalizedFileId,
+ setIsLibraryLoaded,
+ ])
+
+ useEffect(() => {
return () => {
// Save any pending changes before resetting stores
saveOnUnmount()
@@ -347,7 +399,7 @@ export default function App({
// Terminate the worker
terminateWorker()
}
- }, [resetInitialDataPromise, resetStore, resetExcalidrawAPI, terminateWorker, saveOnUnmount])
+ }, [resetStore, resetExcalidrawAPI, terminateWorker, saveOnUnmount])
const [activeCommentThreadId, setActiveCommentThreadId] = useState(null)
const [commentSidebarDocked, setCommentSidebarDocked] = useState(false)
@@ -408,7 +460,116 @@ export default function App({
} catch (error) {
logger.error('[App] Error syncing library items:', error)
}
- }, [isLibraryLoaded])
+ }, [isLibraryLoaded, updateLibraryItems])
+
+ const handleLibrarySaveAs = useCallback((items: LibraryItems) => {
+ if (isReadOnly || isVersionPreview) {
+ return
+ }
+ libraryItemsRef.current = items ?? []
+ setSaveDialogError(null)
+ setLibraryDialogOpen(true)
+ }, [isReadOnly, isVersionPreview])
+
+ const closeSaveDialogs = useCallback(() => {
+ setLibraryDialogOpen(false)
+ setCanvasTemplateDialogOpen(false)
+ setSaveDialogError(null)
+ }, [])
+
+ const clearSaveDialogError = useCallback(() => {
+ setSaveDialogError(null)
+ }, [])
+
+ const submitLibraryDialog = useCallback(async (scope: SaveScope, name: string) => {
+ if (name === '') {
+ setSaveDialogError(t('whiteboard', 'Enter a library name.'))
+ return
+ }
+ const items = libraryItemsRef.current ?? []
+ if (items.length === 0) {
+ setSaveDialogError(t('whiteboard', 'Add at least one shape to your library first.'))
+ return
+ }
+ setIsSavingDialog(true)
+ setSaveDialogError(null)
+ try {
+ await saveLibrary(scope, name, items)
+ showSuccess(scope === 'org'
+ ? t('whiteboard', 'Organization library saved.')
+ : t('whiteboard', 'Library saved.'))
+ setLibraryDialogOpen(false)
+ } catch (error) {
+ const message = (error as { status?: number }).status === 409
+ ? t('whiteboard', 'A canvas template with this name already exists. Choose a different name.')
+ : t('whiteboard', 'Could not save library.')
+ setSaveDialogError(message)
+ showError(message)
+ logger.error('[App] Error saving library:', error)
+ } finally {
+ setIsSavingDialog(false)
+ }
+ }, [saveLibrary])
+
+ const handleSaveAsCanvasTemplate = useCallback(() => {
+ if (isReadOnly || isVersionPreview) {
+ return
+ }
+ setSaveDialogError(null)
+ setCanvasTemplateDialogOpen(true)
+ }, [isReadOnly, isVersionPreview])
+
+ const submitCanvasTemplateDialog = useCallback(async (scope: SaveScope, name: string) => {
+ if (name === '') {
+ setSaveDialogError(t('whiteboard', 'Enter a canvas name.'))
+ return
+ }
+ const api = useExcalidrawStore.getState().excalidrawAPI
+ if (!api) {
+ return
+ }
+ const elements = api.getSceneElements()
+ if (elements.length === 0) {
+ setSaveDialogError(t('whiteboard', 'This whiteboard has no content to save as a canvas.'))
+ return
+ }
+ // Only ship file blobs still referenced by an image element.
+ const allFiles = api.getFiles()
+ const files: typeof allFiles = {}
+ for (const element of elements) {
+ const fileId = (element as { fileId?: string | null }).fileId
+ if (element.type === 'image' && fileId && allFiles[fileId]) {
+ files[fileId] = allFiles[fileId]
+ }
+ }
+ const data = {
+ elements,
+ files,
+ appState: { viewBackgroundColor: api.getAppState().viewBackgroundColor },
+ }
+ if (JSON.stringify(data).length > 15 * 1024 * 1024) {
+ setSaveDialogError(t('whiteboard', 'Canvas is too large (max 15 MB).'))
+ return
+ }
+ setIsSavingDialog(true)
+ setSaveDialogError(null)
+ try {
+ await publishCanvasTemplate(scope, name, data)
+ showSuccess(scope === 'org'
+ ? t('whiteboard', 'Organization canvas published.')
+ : t('whiteboard', 'Canvas saved to your Templates folder.'))
+ setCanvasTemplateDialogOpen(false)
+ } catch (error) {
+ const message = (error as { status?: number }).status === 409
+ ? t('whiteboard', 'A library with this name already exists. Choose a different name.')
+ : t('whiteboard', 'Could not save canvas.')
+ setSaveDialogError(message)
+ showError(message)
+ logger.error('[App] Error saving canvas template:', error)
+ } finally {
+ setIsSavingDialog(false)
+ }
+ }, [publishCanvasTemplate])
const libraryReturnUrl = encodeURIComponent(window.location.href)
@@ -476,6 +637,15 @@ export default function App({
isVersionPreview ? 'App App--version-preview' : 'App'
), [isVersionPreview])
+ const onExcalidrawAPI = useCallback((api: ExcalidrawImperativeAPI | null) => {
+ if (api) {
+ setExcalidrawAPI(api)
+ return
+ }
+
+ resetExcalidrawAPI()
+ }, [resetExcalidrawAPI, setExcalidrawAPI])
+
if (isLoading) {
return (
@@ -531,7 +701,7 @@ export default function App({
validateEmbeddable={() => true}
renderEmbeddable={Embeddable}
beforeElementCreated={beforeElementCreated}
- excalidrawAPI={setExcalidrawAPI}
+ excalidrawAPI={onExcalidrawAPI}
initialData={initialDataPromise}
generateIdForFile={generateIdForFile}
onPointerUpdate={onPointerUpdate}
@@ -548,6 +718,9 @@ export default function App({
onLibraryChange={onLibraryChange}
langCode={lang}
libraryReturnUrl={libraryReturnUrl}
+ libraryMenuTitle={t('whiteboard', 'My library')}
+ onLibrarySaveAs={(isReadOnly || isVersionPreview) ? undefined : handleLibrarySaveAs}
+ onSaveAsCanvasTemplate={(isReadOnly || isVersionPreview) ? undefined : handleSaveAsCanvasTemplate}
>
@@ -623,6 +796,32 @@ export default function App({
settings={creatorDisplaySettings}
/>
)}
+ {libraryDialogOpen && (
+
+ )}
+ {canvasTemplateDialogOpen && (
+
+ )}
)
diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue
index 54674475..f12c2e0d 100644
--- a/src/components/AdminSettings.vue
+++ b/src/components/AdminSettings.vue
@@ -49,6 +49,98 @@
+
+
+ {{ t('whiteboard', 'Organization templates require Nextcloud 30 or later. Upgrade to make them available in the "New whiteboard" picker.') }}
+
+
+
+ {{ t('whiteboard', 'Libraries') }}
+
+
+ {{ t('whiteboard', 'Shape libraries shown to everyone as read-only sections in the whiteboard library panel. Upload an .excalidrawlib file; its shapes appear in every user’s library alongside their own.') }}
+
+
+
+ {{ uploadingOrgLibrary ? t('whiteboard', 'Uploading…') : t('whiteboard', 'Upload library') }}
+
+
+
+
+
+
+ {{ t('whiteboard', 'Loading organization libraries…') }}
+
+
+ {{ t('whiteboard', 'No organization libraries yet. Upload an .excalidrawlib file to share a set of shapes with everyone.') }}
+
+
+
+
+
+ {{ t('whiteboard', 'Canvases') }}
+
+
+ {{ t('whiteboard', 'Whiteboard canvases available to everyone in the "New whiteboard" picker. Upload a .whiteboard file; new boards created from it start with a copy of its content.') }}
+
+
+
+ {{ uploadingOrgCanvasTemplate ? t('whiteboard', 'Uploading…') : t('whiteboard', 'Upload canvas') }}
+
+
+
+
+
+
+ {{ t('whiteboard', 'Loading organization canvases…') }}
+
+
+ {{ t('whiteboard', 'No organization canvases yet. Upload a .whiteboard file to share a starting board with everyone.') }}
+
+
+
+
diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue
index 03683b3d..22ebdd99 100644
--- a/src/components/PersonalSettings.vue
+++ b/src/components/PersonalSettings.vue
@@ -67,6 +67,7 @@ export default {
this.saving = false
}
},
+ t,
},
}
diff --git a/src/components/SaveScopedDialog.tsx b/src/components/SaveScopedDialog.tsx
new file mode 100644
index 00000000..24f2b7cd
--- /dev/null
+++ b/src/components/SaveScopedDialog.tsx
@@ -0,0 +1,97 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { useCallback, useState } from 'react'
+import type { FormEvent } from 'react'
+import { translate as t } from '@nextcloud/l10n'
+
+export type SaveScope = 'personal' | 'org'
+
+interface SaveScopedDialogProps {
+ title: string
+ hint: string
+ nameLabel: string
+ /** Admins get the personal/organization scope toggle. */
+ isAdmin: boolean
+ isSaving: boolean
+ error: string | null
+ onClose: () => void
+ onSubmit: (scope: SaveScope, name: string) => void
+ /** Called when the user edits the name, to clear a stale error. */
+ onErrorClear: () => void
+}
+
+/**
+ * Name + scope form shared by "Save as library" and "Save as canvas template".
+ */
+export function SaveScopedDialog({
+ title,
+ hint,
+ nameLabel,
+ isAdmin,
+ isSaving,
+ error,
+ onClose,
+ onSubmit,
+ onErrorClear,
+}: SaveScopedDialogProps) {
+ const [scope, setScope] = useState('personal')
+ const [name, setName] = useState('')
+
+ const close = useCallback(() => {
+ if (!isSaving) {
+ onClose()
+ }
+ }, [isSaving, onClose])
+
+ const submit = useCallback((event: FormEvent) => {
+ event.preventDefault()
+ if (!isSaving) {
+ onSubmit(scope, name.trim())
+ }
+ }, [isSaving, onSubmit, scope, name])
+
+ return (
+ { if (e.target === e.currentTarget) { close() } }}>
+
+
+ )
+}
diff --git a/src/files.ts b/src/files.ts
new file mode 100644
index 00000000..e9842e0c
--- /dev/null
+++ b/src/files.ts
@@ -0,0 +1,233 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * Groups the native "New whiteboard" template picker into two labelled
+ * sections — Personal (the blank board plus your own templates and saved
+ * libraries) and Organization (admin-published ones) — and badges each tile
+ * as Canvas template (full board copy) or Library (shape kit resolved live).
+ *
+ * Entry metadata comes from GET /apps/whiteboard/picker, a fileid -> {kind,
+ * scope} map; the blank tile (fileid -1) needs no lookup.
+ *
+ * Intentionally dependency-free: this script is injected standalone into the
+ * Files app, so importing @nextcloud/* (which drags in heavy shared chunks)
+ * risks a module-load failure. Uses fetch + NC globals only.
+ */
+
+const WHITEBOARD_APP = 'whiteboard'
+
+type Scope = 'personal' | 'org'
+type Kind = 'canvas-template' | 'library'
+type Entry = { kind: Kind; scope: Scope }
+
+const ORDER: Scope[] = ['personal', 'org']
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const oc = (): any => (globalThis as any).OC
+
+function tr(text: string): string {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const translate = (globalThis as any).t
+ return typeof translate === 'function' ? translate(WHITEBOARD_APP, text) : text
+}
+
+function heading(scope: Scope): string {
+ return scope === 'personal' ? tr('Personal') : tr('Organization')
+}
+
+function badgeLabel(kind: Kind): string {
+ return kind === 'library' ? tr('Library') : tr('Canvas')
+}
+
+let entries: Map | null = null
+let loadingEntries = false
+// File ids that stayed unknown after a map refresh — don't refetch for them
+// until the negative cache expires (a template saved after the last fetch
+// becomes resolvable on the next picker open).
+const unresolvedIds = new Set()
+let unresolvedAt = 0
+const UNRESOLVED_RETRY_MS = 30_000
+
+async function loadEntries(): Promise> {
+ const map = new Map()
+ try {
+ const OC = oc()
+ const url = `${OC?.webroot ?? ''}/index.php/apps/whiteboard/picker`
+ const response = await globalThis.fetch(url, {
+ headers: {
+ Accept: 'application/json',
+ requesttoken: OC?.requestToken ?? '',
+ },
+ credentials: 'include',
+ })
+ if (!response.ok) {
+ return map
+ }
+ const json = await response.json()
+ const raw = json?.entries ?? {}
+ for (const [fileid, entry] of Object.entries(raw)) {
+ const kind = (entry as Entry)?.kind === 'library' ? 'library' : 'canvas-template'
+ const scope = (entry as Entry)?.scope === 'org' ? 'org' : 'personal'
+ map.set(String(fileid), { kind, scope })
+ }
+ } catch (error) {
+ // best-effort; leave the picker untouched on failure
+ }
+ return map
+}
+
+const ID_PREFIX = 'template-picker-'
+
+function fileIdOf(item: Element): string | null {
+ const input = item.querySelector('input[type="radio"]') as HTMLInputElement | null
+ const id = input?.id
+ if (!id) {
+ return null
+ }
+ // e.g. "template-picker-1955" -> "1955", "template-picker--1" -> "-1".
+ return id.startsWith(ID_PREFIX) ? id.slice(ID_PREFIX.length) : id
+}
+
+function scopeOf(fileid: string | null): Scope {
+ if (fileid === '-1') {
+ return 'personal'
+ }
+ return (fileid && entries?.get(fileid)?.scope) || 'personal'
+}
+
+function kindOf(fileid: string | null): Kind | null {
+ if (fileid === '-1') {
+ return null
+ }
+ return (fileid && entries?.get(fileid)?.kind) || 'canvas-template'
+}
+
+// Compact the tiles via INLINE styles — the component sizes previews with
+// min/max-height: var(--height) and width: var(--width), which an injected
+// stylesheet can't reliably beat. Inline styles win without !important.
+function styleTile(item: HTMLElement, kind: Kind | null): void {
+ const preview = item.querySelector('.template-picker__preview') as HTMLElement | null
+ if (preview) {
+ preview.style.width = '100%'
+ preview.style.minHeight = '0'
+ preview.style.maxHeight = '76px'
+ preview.style.height = '76px'
+ preview.style.position = 'relative'
+ preview.querySelectorAll('.whiteboard-picker__badge').forEach((el) => el.remove())
+ if (kind) {
+ const badge = document.createElement('span')
+ badge.className = 'whiteboard-picker__badge'
+ badge.textContent = badgeLabel(kind)
+ badge.style.cssText = 'position:absolute;top:4px;right:4px;padding:1px 6px;border-radius:10px;'
+ + 'font-size:.6875rem;font-weight:600;pointer-events:none;'
+ + 'background:var(--color-primary-element-light, #e2e6ff);'
+ + 'color:var(--color-primary-element-light-text, #333)'
+ preview.appendChild(badge)
+ }
+ }
+ const image = item.querySelector('.template-picker__image') as HTMLElement | null
+ if (image) {
+ image.style.width = 'auto'
+ image.style.height = 'auto'
+ image.style.maxWidth = '48px'
+ image.style.maxHeight = '48px'
+ image.style.margin = 'auto'
+ }
+ const title = item.querySelector('.template-picker__title') as HTMLElement | null
+ if (title) {
+ title.style.fontSize = '0.8125rem'
+ title.style.lineHeight = '1.2'
+ }
+}
+
+function enhance(list: HTMLElement): void {
+ const items = Array.from(list.querySelectorAll('.template-picker__item')) as HTMLElement[]
+ if (items.length === 0) {
+ return
+ }
+
+ // Only the whiteboard picker: at least one non-blank tile must be a known
+ // whiteboard entry. Otherwise this is some other file type's picker.
+ const isWhiteboard = items.some((item) => {
+ const id = fileIdOf(item)
+ return id !== null && id !== '-1' && !!entries?.has(id)
+ })
+ if (!isWhiteboard) {
+ return
+ }
+
+ const grouped: Record = { personal: [], org: [] }
+ for (const item of items) {
+ grouped[scopeOf(fileIdOf(item))].push(item)
+ }
+
+ // Compact, left-aligned grid (inline so it beats the component's var-based grid).
+ list.style.gridTemplateColumns = 'repeat(auto-fill, 124px)'
+ list.style.gridAutoRows = 'auto'
+ list.style.gap = '2px 16px'
+ list.style.justifyContent = 'start'
+ list.style.maxWidth = 'none'
+ list.querySelectorAll('.whiteboard-picker__heading').forEach((el) => el.remove())
+
+ for (const scope of ORDER) {
+ const tiles = grouped[scope]
+ if (tiles.length === 0) {
+ continue
+ }
+ const header = document.createElement('li')
+ header.className = 'whiteboard-picker__heading'
+ header.textContent = heading(scope)
+ header.style.cssText = 'grid-column:1/-1;margin:14px 0 2px;font-weight:600;font-size:.9rem;color:var(--color-text-maxcontrast)'
+ list.appendChild(header)
+ for (const tile of tiles) {
+ styleTile(tile, kindOf(fileIdOf(tile)))
+ list.appendChild(tile)
+ }
+ }
+
+ list.dataset.whiteboardEnhanced = '1'
+}
+
+async function tick(): Promise {
+ const list = document.querySelector(
+ '.templates-picker__list:not([data-whiteboard-enhanced])',
+ ) as HTMLElement | null
+ if (!list || list.querySelectorAll('.template-picker__item').length === 0) {
+ return
+ }
+ if (loadingEntries) {
+ return
+ }
+ // (Re)load the map when it is missing or when the picker shows files saved
+ // after the last fetch — otherwise new entries get default kind/scope.
+ const ids = Array.from(list.querySelectorAll('.template-picker__item'))
+ .map(fileIdOf)
+ .filter((id): id is string => id !== null && id !== '-1')
+ const negativeCacheFresh = Date.now() - unresolvedAt < UNRESOLVED_RETRY_MS
+ const hasUnknown = ids.some((id) => !entries?.has(id) && !(negativeCacheFresh && unresolvedIds.has(id)))
+ if (!entries || hasUnknown) {
+ loadingEntries = true
+ entries = await loadEntries()
+ loadingEntries = false
+ unresolvedIds.clear()
+ for (const id of ids) {
+ if (!entries.has(id)) {
+ unresolvedIds.add(id)
+ }
+ }
+ unresolvedAt = Date.now()
+ }
+ try {
+ enhance(list)
+ } catch (error) {
+ // ignore – leave picker flat on failure
+ }
+}
+
+const intervalId = globalThis.setInterval(() => {
+ tick().catch(() => {})
+}, 400)
+globalThis.addEventListener('pagehide', () => {
+ globalThis.clearInterval(intervalId)
+})
diff --git a/src/hooks/useBoardDataManager.ts b/src/hooks/useBoardDataManager.ts
index 699c11b6..644ed1e6 100644
--- a/src/hooks/useBoardDataManager.ts
+++ b/src/hooks/useBoardDataManager.ts
@@ -27,6 +27,7 @@ export function useBoardDataManager() {
fileId,
resolveInitialData,
resetInitialDataPromise,
+ setLibraryRef,
isVersionPreview,
versionSource,
fileVersion,
@@ -34,6 +35,7 @@ export function useBoardDataManager() {
fileId: state.fileId,
resolveInitialData: state.resolveInitialData,
resetInitialDataPromise: state.resetInitialDataPromise,
+ setLibraryRef: state.setLibraryRef,
isVersionPreview: state.isVersionPreview,
versionSource: state.versionSource,
fileVersion: state.fileVersion,
@@ -187,6 +189,9 @@ export function useBoardDataManager() {
return
}
+ // The library (if any) this board was created from; resolved live in App.
+ setLibraryRef(serverData?.libraryRef ?? null)
+
let dataToUse = null
if (serverData && serverData.elements && Array.isArray(serverData.elements)) {
@@ -308,7 +313,7 @@ export function useBoardDataManager() {
}, 50)
loadingTimeoutsRef.current.add(timeout)
}
- }, [fileId, resolveInitialData, fetchDataFromServer, isVersionPreview, versionSource, fileVersion])
+ }, [fileId, resolveInitialData, setLibraryRef, fetchDataFromServer, isVersionPreview, versionSource, fileVersion])
const saveOnUnmount = useCallback(() => {
if (useWhiteboardConfigStore.getState().isVersionPreview) {
diff --git a/src/hooks/useCanvasTemplate.ts b/src/hooks/useCanvasTemplate.ts
new file mode 100644
index 00000000..55699078
--- /dev/null
+++ b/src/hooks/useCanvasTemplate.ts
@@ -0,0 +1,42 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { useCallback } from 'react'
+import { useJWTStore } from '../stores/useJwtStore'
+import { generateUrl } from '@nextcloud/router'
+import type { BinaryFiles } from '@excalidraw/excalidraw/types/types'
+
+export interface CanvasTemplateData {
+ elements: readonly unknown[]
+ files: BinaryFiles
+ appState?: Record
+}
+
+export function useCanvasTemplate() {
+ const getJWT = useJWTStore(state => state.getJWT)
+
+ const publishCanvasTemplate = useCallback(async (scope: 'personal' | 'org', name: string, data: CanvasTemplateData): Promise => {
+ const jwt = await getJWT()
+ if (!jwt) {
+ throw new Error('No JWT')
+ }
+ const response = await globalThis.fetch(generateUrl('apps/whiteboard/canvas-template'), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ Authorization: `Bearer ${jwt}`,
+ },
+ body: JSON.stringify({ scope, name, data }),
+ })
+ if (!response.ok) {
+ const err = new Error(`Failed to publish template: ${response.statusText}`) as Error & { status?: number }
+ err.status = response.status
+ throw err
+ }
+ }, [getJWT])
+
+ return { publishCanvasTemplate }
+}
diff --git a/src/hooks/useLibrary.ts b/src/hooks/useLibrary.ts
index 7721c7ab..7472aba3 100644
--- a/src/hooks/useLibrary.ts
+++ b/src/hooks/useLibrary.ts
@@ -12,6 +12,9 @@ import logger from '../utils/logger'
type LibraryItemExtended = LibraryItem & {
filename?: string;
+ // Set on items resolved from a saved/org library (read-only section in the
+ // editor); never persisted back into the personal library.
+ libraryName?: string;
}
export function useLibrary() {
@@ -90,7 +93,7 @@ export function useLibrary() {
logger.error('[Library] Error fetching library:', error)
return null
}
- })
+ }, [getJWT])
const updateLibraryItems = useCallback(async (items: LibraryItems): Promise => {
try {
@@ -99,6 +102,9 @@ export function useLibrary() {
logger.warn('[Library] No JWT found, cannot update library')
return
}
+ // Persist only the writable "My library" items — items tagged with a
+ // libraryName belong to a read-only source resolved per board.
+ const itemsToSave = items.filter(item => !(item as LibraryItemExtended).libraryName)
const url = generateUrl('apps/whiteboard/library')
const response = await globalThis.fetch(url, {
method: 'PUT',
@@ -107,7 +113,7 @@ export function useLibrary() {
'X-Requested-With': 'XMLHttpRequest',
Authorization: `Bearer ${jwt}`,
},
- body: JSON.stringify({ items }),
+ body: JSON.stringify({ items: itemsToSave }),
})
if (!response.ok) {
@@ -116,7 +122,7 @@ export function useLibrary() {
} catch (error) {
logger.error('[Library] Error updating library:', error)
}
- })
+ }, [getJWT])
return {
fetchLibraryItems,
diff --git a/src/hooks/useLibraryCatalog.ts b/src/hooks/useLibraryCatalog.ts
new file mode 100644
index 00000000..6b25479b
--- /dev/null
+++ b/src/hooks/useLibraryCatalog.ts
@@ -0,0 +1,103 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { useCallback } from 'react'
+import { useJWTStore } from '../stores/useJwtStore'
+import { generateUrl } from '@nextcloud/router'
+import type { LibraryItems } from '@excalidraw/excalidraw/types/types'
+import logger from '../utils/logger'
+
+export type LibraryScope = 'personal' | 'org'
+
+export type LibraryEntry = { name: string; itemCount: number }
+
+export type LibraryCatalog = { personal: LibraryEntry[]; org: LibraryEntry[] }
+
+const authHeaders = (jwt: string) => ({
+ 'Content-Type': 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ Authorization: `Bearer ${jwt}`,
+})
+
+export function useLibraryCatalog() {
+ const getJWT = useJWTStore(state => state.getJWT)
+
+ const fetchLibraries = useCallback(async (): Promise => {
+ const empty: LibraryCatalog = { personal: [], org: [] }
+ try {
+ const jwt = await getJWT()
+ if (!jwt) {
+ return empty
+ }
+ const response = await globalThis.fetch(generateUrl('apps/whiteboard/libraries'), {
+ method: 'GET',
+ headers: authHeaders(jwt),
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to list libraries: ${response.statusText}`)
+ }
+ const json = await response.json()
+ const data = json?.data ?? {}
+ return {
+ personal: Array.isArray(data.personal) ? data.personal : [],
+ org: Array.isArray(data.org) ? data.org : [],
+ }
+ } catch (error) {
+ logger.error('[Library] Error listing libraries:', error)
+ return empty
+ }
+ }, [getJWT])
+
+ const resolveLibrary = useCallback(async (scope: string, name: string): Promise => {
+ const jwt = await getJWT()
+ if (!jwt) {
+ return []
+ }
+ const url = `${generateUrl('apps/whiteboard/libraries/resolve')}?scope=${encodeURIComponent(scope)}&name=${encodeURIComponent(name)}`
+ const response = await globalThis.fetch(url, {
+ method: 'GET',
+ headers: authHeaders(jwt),
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to resolve library: ${response.statusText}`)
+ }
+ const json = await response.json()
+ return Array.isArray(json?.data) ? json.data : []
+ }, [getJWT])
+
+ const saveLibrary = useCallback(async (scope: LibraryScope, name: string, items: LibraryItems): Promise => {
+ const jwt = await getJWT()
+ if (!jwt) {
+ throw new Error('No JWT')
+ }
+ const response = await globalThis.fetch(generateUrl('apps/whiteboard/libraries'), {
+ method: 'POST',
+ headers: authHeaders(jwt),
+ body: JSON.stringify({ scope, name, items }),
+ })
+ if (!response.ok) {
+ const err = new Error(`Failed to save library: ${response.statusText}`) as Error & { status?: number }
+ err.status = response.status
+ throw err
+ }
+ }, [getJWT])
+
+ const deleteLibrary = useCallback(async (scope: LibraryScope, name: string): Promise => {
+ const jwt = await getJWT()
+ if (!jwt) {
+ throw new Error('No JWT')
+ }
+ const url = `${generateUrl('apps/whiteboard/libraries')}/${encodeURIComponent(scope)}/${encodeURIComponent(name)}`
+ const response = await globalThis.fetch(url, {
+ method: 'DELETE',
+ headers: authHeaders(jwt),
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to delete library: ${response.statusText}`)
+ }
+ }, [getJWT])
+
+ return { fetchLibraries, resolveLibrary, saveLibrary, deleteLibrary }
+}
diff --git a/src/stores/useWhiteboardConfigStore.ts b/src/stores/useWhiteboardConfigStore.ts
index 3a726c4e..7fea574b 100644
--- a/src/stores/useWhiteboardConfigStore.ts
+++ b/src/stores/useWhiteboardConfigStore.ts
@@ -9,6 +9,8 @@ import type { ExcalidrawInitialDataState } from '@excalidraw/excalidraw/types/ty
type InitialDataPromise = ReturnType
+export type LibraryRef = { scope: string; name: string }
+
interface WhiteboardConfigState {
// Core state
fileId: number
@@ -22,6 +24,7 @@ interface WhiteboardConfigState {
isVersionPreview: boolean
versionSource: string | null
fileVersion: string | null
+ libraryRef: LibraryRef | null // library this board was created from (resolved live)
// UI state
zenModeEnabled: boolean
@@ -46,6 +49,7 @@ interface WhiteboardConfigState {
resolveInitialData: (data: ExcalidrawInitialDataState) => void
resetInitialDataPromise: () => void
resetStore: () => void // Reset the entire store state
+ setLibraryRef: (ref: LibraryRef | null) => void
// UI actions
setZenModeEnabled: (enabled: boolean) => void
@@ -69,6 +73,7 @@ export const useWhiteboardConfigStore = create()((set, ge
isVersionPreview: false,
versionSource: null,
fileVersion: null,
+ libraryRef: null,
// UI state
zenModeEnabled: false,
@@ -126,11 +131,14 @@ export const useWhiteboardConfigStore = create()((set, ge
isReadOnly: false,
initialDataPromise: createResolvablePromise(),
pendingInitialDataPromises: [],
+ libraryRef: null,
zenModeEnabled: false,
gridModeEnabled: false,
})
},
+ setLibraryRef: (ref: LibraryRef | null) => set({ libraryRef: ref }),
+
// UI actions
setZenModeEnabled: (enabled: boolean) => set({ zenModeEnabled: enabled }),
diff --git a/src/styles/globals/_layout.scss b/src/styles/globals/_layout.scss
index 7e305384..d7625754 100644
--- a/src/styles/globals/_layout.scss
+++ b/src/styles/globals/_layout.scss
@@ -237,6 +237,120 @@
}
}
+.save-scoped-dialog__backdrop {
+ position: absolute;
+ inset: 0;
+ z-index: 100021;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ background: rgba(0, 0, 0, 0.35);
+}
+
+.save-scoped-dialog {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ width: min(420px, 100%);
+ padding: 20px;
+ color: var(--color-main-text);
+ background: var(--color-main-background);
+ border-radius: var(--border-radius-large);
+ box-shadow: 0 4px 18px var(--color-box-shadow);
+
+ h2 {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 700;
+ }
+
+ p {
+ margin: 0;
+ }
+
+ label {
+ font-weight: 600;
+ }
+
+ input[type="text"] {
+ width: 100%;
+ min-height: 38px;
+ padding: 8px 10px;
+ color: var(--color-main-text);
+ background: var(--color-main-background);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius);
+ }
+}
+
+.save-scoped-dialog__hint {
+ color: var(--color-text-maxcontrast);
+ line-height: 1.4;
+}
+
+.save-scoped-dialog__scope {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin: 0;
+ padding: 0;
+ border: none;
+
+ legend {
+ margin-bottom: 4px;
+ padding: 0;
+ font-weight: 600;
+ }
+}
+
+.save-scoped-dialog__scope-option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 400;
+ cursor: pointer;
+
+ input[type="radio"] {
+ flex: none;
+ width: auto;
+ min-height: 0;
+ margin: 0;
+ }
+}
+
+.save-scoped-dialog__error {
+ color: var(--color-text-error, var(--color-error, #d93025));
+ font-weight: 600;
+}
+
+.save-scoped-dialog__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.save-scoped-dialog__button {
+ min-height: 34px;
+ padding: 6px 14px;
+ color: var(--color-main-text);
+ background: var(--color-background-hover);
+ border: none;
+ border-radius: var(--border-radius);
+ cursor: pointer;
+
+ &:disabled {
+ cursor: default;
+ opacity: 0.6;
+ }
+}
+
+.save-scoped-dialog__button--primary {
+ color: var(--color-primary-text);
+ background: var(--color-primary);
+}
+
.version-preview-banner {
position: absolute;
top: calc(var(--default-grid-baseline) * 2);
diff --git a/tests/Unit/AppInfo/ApplicationTest.php b/tests/Unit/AppInfo/ApplicationTest.php
index ed1367c8..1f7fe636 100644
--- a/tests/Unit/AppInfo/ApplicationTest.php
+++ b/tests/Unit/AppInfo/ApplicationTest.php
@@ -9,10 +9,13 @@
namespace OCA\Whiteboard\AppInfo;
+use OCP\AppFramework\Bootstrap\IRegistrationContext;
+
class ApplicationTest extends \Test\TestCase {
public function testApp(): void {
- $registrationContext = $this->createMock(\OCP\AppFramework\Bootstrap\IRegistrationContext::class);
+ $registrationContext = $this->createMock(IRegistrationContext::class);
+
$app = new Application();
$app->register($registrationContext);
self::assertTrue(true);
diff --git a/tests/Unit/Controller/PickerControllerTest.php b/tests/Unit/Controller/PickerControllerTest.php
new file mode 100644
index 00000000..9be24bb5
--- /dev/null
+++ b/tests/Unit/Controller/PickerControllerTest.php
@@ -0,0 +1,135 @@
+createMock(File::class);
+ $file->method('getId')->willReturn($id);
+ $file->method('getName')->willReturn($name);
+ $file->method('getContent')->willReturn($content);
+ $file->method('getSize')->willReturn($size ?? strlen($content));
+ return $file;
+ }
+
+ /** @return Folder&MockObject */
+ private function mockListingFolder(array $children): Folder {
+ $folder = $this->createMock(Folder::class);
+ $folder->method('getDirectoryListing')->willReturn(array_values($children));
+ return $folder;
+ }
+
+ public function testIndexMergesOrgAndPersonalEntries(): void {
+ $pointerContent = json_encode(['elements' => [], 'libraryRef' => ['scope' => 'org', 'name' => 'Kit']], JSON_THROW_ON_ERROR);
+
+ $orgPointers = $this->mockListingFolder([
+ $this->mockFile(11, 'Kit.whiteboard', $pointerContent),
+ ]);
+ $orgCanvasTemplates = $this->mockListingFolder([
+ $this->mockFile(22, 'Kanban.whiteboard', json_encode(['elements' => [[]]], JSON_THROW_ON_ERROR)),
+ ]);
+ $userTemplates = $this->mockListingFolder([
+ // Small pointer file -> library.
+ $this->mockFile(33, 'My kit.whiteboard', json_encode(['elements' => [], 'libraryRef' => ['scope' => 'personal', 'name' => 'My kit']], JSON_THROW_ON_ERROR)),
+ // Big file -> template, content never decoded.
+ $this->mockFile(44, 'Big board.whiteboard', '{}', 10 * 1024 * 1024),
+ // Small real board -> template.
+ $this->mockFile(55, 'Small board.whiteboard', json_encode(['elements' => [[]]], JSON_THROW_ON_ERROR)),
+ // Non-whiteboard files are ignored.
+ $this->mockFile(66, 'notes.txt', 'hello'),
+ ]);
+
+ $templateManager = $this->createMock(ITemplateManager::class);
+ $templateManager->method('hasTemplateDirectory')->willReturn(true);
+ $templateManager->method('getTemplatePath')->willReturn('Templates/');
+
+ $userFolder = $this->createMock(Folder::class);
+ $userFolder->method('get')->with('Templates/')->willReturn($userTemplates);
+
+ $rootFolder = $this->createMock(IRootFolder::class);
+ $rootFolder->method('getUserFolder')->with('alice')->willReturn($userFolder);
+ $rootFolder->method('get')->willReturnCallback(static function (string $path) use ($orgPointers, $orgCanvasTemplates) {
+ return match ($path) {
+ 'appdata_test/whiteboard/library-pointers' => $orgPointers,
+ 'appdata_test/whiteboard/templates' => $orgCanvasTemplates,
+ default => throw new NotFoundException($path),
+ };
+ });
+
+ $config = $this->createMock(IConfig::class);
+ $config->method('getSystemValueString')->with('instanceid', '')->willReturn('test');
+
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('alice');
+ $userSession = $this->createMock(IUserSession::class);
+ $userSession->method('getUser')->willReturn($user);
+
+ $logger = $this->createMock(LoggerInterface::class);
+ $folders = new WhiteboardFolderService($templateManager, $rootFolder, $config, $logger);
+ $controller = new PickerController(
+ $this->createMock(IRequest::class),
+ $userSession,
+ new WhiteboardLibraryService($folders, $config, $logger),
+ new CanvasTemplateService($folders, $logger),
+ $folders,
+ $logger,
+ );
+
+ $entries = $controller->index()->getData()['entries'];
+
+ $this->assertSame(['kind' => 'library', 'scope' => 'org'], $entries['11']);
+ $this->assertSame(['kind' => 'canvas-template', 'scope' => 'org'], $entries['22']);
+ $this->assertSame(['kind' => 'library', 'scope' => 'personal'], $entries['33']);
+ $this->assertSame(['kind' => 'canvas-template', 'scope' => 'personal'], $entries['44']);
+ $this->assertSame(['kind' => 'canvas-template', 'scope' => 'personal'], $entries['55']);
+ $this->assertArrayNotHasKey('66', $entries);
+ }
+
+ public function testIndexIsEmptyAndSafeWithoutSessionUserAndAppData(): void {
+ $rootFolder = $this->createMock(IRootFolder::class);
+ $rootFolder->method('get')->willReturnCallback(static fn (string $path) => throw new NotFoundException($path));
+
+ $config = $this->createMock(IConfig::class);
+ $config->method('getSystemValueString')->with('instanceid', '')->willReturn('test');
+
+ $userSession = $this->createMock(IUserSession::class);
+ $userSession->method('getUser')->willReturn(null);
+
+ $logger = $this->createMock(LoggerInterface::class);
+ $folders = new WhiteboardFolderService($this->createMock(ITemplateManager::class), $rootFolder, $config, $logger);
+ $controller = new PickerController(
+ $this->createMock(IRequest::class),
+ $userSession,
+ new WhiteboardLibraryService($folders, $config, $logger),
+ new CanvasTemplateService($folders, $logger),
+ $folders,
+ $logger,
+ );
+
+ $this->assertSame([], $controller->index()->getData()['entries']);
+ }
+}
diff --git a/tests/Unit/Controller/WhiteboardControllerTest.php b/tests/Unit/Controller/WhiteboardControllerTest.php
new file mode 100644
index 00000000..fafbd028
--- /dev/null
+++ b/tests/Unit/Controller/WhiteboardControllerTest.php
@@ -0,0 +1,179 @@
+ File mock. */
+ private array $templatesChildren = [];
+
+ private function makeController(bool $isAdmin, array $params): WhiteboardController {
+ $request = $this->createMock(IRequest::class);
+ $jwt = JWT::encode(['userid' => self::UID, 'exp' => time() + 300], self::SECRET, JWTConsts::JWT_ALGORITHM);
+ $request->method('getHeader')->willReturnCallback(static fn (string $name): string => $name === 'Authorization' ? 'Bearer ' . $jwt : '');
+ $request->method('getParam')->willReturnCallback(static fn (string $key, $default = null) => $params[$key] ?? $default);
+
+ $appConfig = $this->createMock(IAppConfig::class);
+ $appConfig->method('getAppValueString')->willReturn(self::SECRET);
+ $configService = new ConfigService($appConfig, $this->createMock(IConfig::class));
+
+ $templateManager = $this->createMock(ITemplateManager::class);
+ $templateManager->method('hasTemplateDirectory')->willReturn(true);
+ $templateManager->method('getTemplatePath')->willReturn('Templates/');
+
+ $templatesFolder = $this->createMock(Folder::class);
+ $templatesFolder->method('nodeExists')->willReturnCallback(fn (string $name): bool => isset($this->templatesChildren[$name]));
+ $templatesFolder->method('get')->willReturnCallback(fn (string $name) => $this->templatesChildren[$name]);
+ $templatesFolder->method('newFile')->willReturnCallback(function (string $name) {
+ $file = $this->createMock(File::class);
+ $file->method('getName')->willReturn($name);
+ $file->method('getContent')->willReturn('');
+ $this->templatesChildren[$name] = $file;
+ return $file;
+ });
+ $templatesFolder->method('newFolder')->willReturnCallback(function (string $name) {
+ $folder = $this->createMock(Folder::class);
+ $folder->method('newFile')->willReturnCallback(function (string $fileName) {
+ $file = $this->createMock(File::class);
+ $file->method('getName')->willReturn($fileName);
+ return $file;
+ });
+ $this->templatesChildren[$name] = $folder;
+ return $folder;
+ });
+
+ $userFolder = $this->createMock(Folder::class);
+ $userFolder->method('get')->with('Templates/')->willReturn($templatesFolder);
+
+ $rootFolder = $this->createMock(IRootFolder::class);
+ $rootFolder->method('getUserFolder')->with(self::UID)->willReturn($userFolder);
+
+ $config = $this->createMock(IConfig::class);
+ $config->method('getSystemValueString')->with('instanceid', '')->willReturn('test');
+
+ $logger = $this->createMock(LoggerInterface::class);
+ $folders = new WhiteboardFolderService($templateManager, $rootFolder, $config, $logger, static fn (): bool => true);
+
+ $cache = $this->createMock(IMemcache::class);
+ $cacheFactory = $this->createMock(ICacheFactory::class);
+ $cacheFactory->method('createLocking')->willReturn($cache);
+
+ $groupManager = $this->createMock(IGroupManager::class);
+ $groupManager->method('isAdmin')->with(self::UID)->willReturn($isAdmin);
+
+ return new WhiteboardController(
+ 'whiteboard',
+ $request,
+ new GetUserFromIdServiceFactory(
+ $this->createMock(ShareManager::class),
+ $this->createMock(IUserManager::class),
+ $this->createMock(IUserSession::class),
+ ),
+ new GetFileServiceFactory($rootFolder, $this->createMock(ShareManager::class), $logger),
+ new JWTService($configService),
+ new WhiteboardContentService($logger),
+ new WhiteboardLibraryService($folders, $config, $logger),
+ new CanvasTemplateService($folders, $logger),
+ new ExceptionService($logger),
+ $configService,
+ $logger,
+ $cacheFactory,
+ $groupManager,
+ );
+ }
+
+ public function testSaveLibraryOrgScopeForbiddenForNonAdmin(): void {
+ $controller = $this->makeController(false, [
+ 'scope' => 'org',
+ 'name' => 'Kit',
+ 'items' => [['elements' => [['type' => 'rectangle']]]],
+ ]);
+
+ $response = $controller->saveLibrary();
+
+ $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus());
+ }
+
+ public function testDeleteLibraryOrgScopeForbiddenForNonAdmin(): void {
+ $controller = $this->makeController(false, []);
+
+ $response = $controller->deleteLibrary('org', 'Kit');
+
+ $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus());
+ }
+
+ public function testPublishTemplateOrgScopeForbiddenForNonAdmin(): void {
+ $controller = $this->makeController(false, [
+ 'scope' => 'org',
+ 'name' => 'Kanban',
+ 'data' => ['elements' => [['type' => 'rectangle']]],
+ ]);
+
+ $response = $controller->publishCanvasTemplate();
+
+ $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus());
+ }
+
+ public function testPublishTemplatePersonalCreatesTemplateFile(): void {
+ $controller = $this->makeController(false, [
+ 'scope' => 'personal',
+ 'name' => 'Retro board',
+ 'data' => ['elements' => [['type' => 'rectangle']]],
+ ]);
+
+ $response = $controller->publishCanvasTemplate();
+
+ $this->assertSame(Http::STATUS_CREATED, $response->getStatus());
+ $this->assertSame(['name' => 'Retro board', 'scope' => 'personal'], $response->getData()['canvasTemplate']);
+ $this->assertArrayHasKey('Retro board.whiteboard', $this->templatesChildren);
+ }
+
+ public function testPublishTemplateRejectsNonArrayData(): void {
+ $controller = $this->makeController(true, [
+ 'scope' => 'personal',
+ 'name' => 'Bad',
+ 'data' => 'not-an-array',
+ ]);
+
+ $response = $controller->publishCanvasTemplate();
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+}
diff --git a/tests/Unit/Service/CanvasTemplateServiceTest.php b/tests/Unit/Service/CanvasTemplateServiceTest.php
new file mode 100644
index 00000000..24e5a2de
--- /dev/null
+++ b/tests/Unit/Service/CanvasTemplateServiceTest.php
@@ -0,0 +1,260 @@
+ node mock. */
+ private array $templatesChildren = [];
+ /** Children of appdata whiteboard/templates, name => node mock. */
+ private array $orgCanvasTemplates = [];
+ private bool $orgCanvasTemplatesDirExists = false;
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ $templateManager = $this->createMock(ITemplateManager::class);
+ $templateManager->method('hasTemplateDirectory')->willReturn(true);
+ $templateManager->method('getTemplatePath')->willReturn('Templates/');
+
+ $templatesFolder = $this->mockFolder($this->templatesChildren);
+ $userFolder = $this->createMock(Folder::class);
+ $userFolder->method('get')->with('Templates/')->willReturn($templatesFolder);
+
+ $orgCanvasTemplatesFolder = $this->mockFolder($this->orgCanvasTemplates);
+
+ $whiteboardFolder = $this->createMock(Folder::class);
+ $whiteboardFolder->method('nodeExists')->with('templates')->willReturnCallback(fn (): bool => $this->orgCanvasTemplatesDirExists);
+ $whiteboardFolder->method('get')->with('templates')->willReturn($orgCanvasTemplatesFolder);
+ $whiteboardFolder->method('newFolder')->willReturnCallback(function () use ($orgCanvasTemplatesFolder): Folder {
+ $this->orgCanvasTemplatesDirExists = true;
+ return $orgCanvasTemplatesFolder;
+ });
+
+ $appDataRoot = $this->createMock(Folder::class);
+ $appDataRoot->method('nodeExists')->with('whiteboard')->willReturn(true);
+ $appDataRoot->method('get')->with('whiteboard')->willReturn($whiteboardFolder);
+
+ $rootFolder = $this->createMock(IRootFolder::class);
+ $rootFolder->method('getUserFolder')->with(self::UID)->willReturn($userFolder);
+ $rootFolder->method('nodeExists')->willReturnCallback(static fn (string $n): bool => $n === 'appdata_test');
+ $rootFolder->method('get')->willReturnCallback(function (string $path) use ($appDataRoot, $orgCanvasTemplatesFolder) {
+ if ($path === 'appdata_test') {
+ return $appDataRoot;
+ }
+ if ($path === 'appdata_test/whiteboard/templates' && $this->orgCanvasTemplatesDirExists) {
+ return $orgCanvasTemplatesFolder;
+ }
+ throw new \OCP\Files\NotFoundException($path);
+ });
+
+ $config = $this->createMock(IConfig::class);
+ $config->method('getSystemValueString')->with('instanceid', '')->willReturn('test');
+
+ $logger = $this->createMock(LoggerInterface::class);
+ $folders = new WhiteboardFolderService($templateManager, $rootFolder, $config, $logger, static fn (): bool => true);
+ $this->service = new CanvasTemplateService($folders, $logger);
+ }
+
+ /** @return Folder&MockObject */
+ private function mockFolder(array &$children): Folder {
+ $folder = $this->createMock(Folder::class);
+ $folder->method('nodeExists')->willReturnCallback(static function (string $name) use (&$children): bool {
+ return isset($children[$name]);
+ });
+ $folder->method('get')->willReturnCallback(static function (string $name) use (&$children) {
+ if (!isset($children[$name])) {
+ throw new \OCP\Files\NotFoundException($name);
+ }
+ return $children[$name];
+ });
+ $folder->method('getDirectoryListing')->willReturnCallback(static function () use (&$children): array {
+ return array_values($children);
+ });
+ $folder->method('newFile')->willReturnCallback(function (string $name) use (&$children) {
+ $children[$name] = $this->mockWritableFile($name);
+ return $children[$name];
+ });
+ return $folder;
+ }
+
+ /** @return File&MockObject */
+ private function mockWritableFile(string $name, string $content = ''): File {
+ $state = new \stdClass();
+ $state->content = $content;
+ $file = $this->createMock(File::class);
+ $file->method('getName')->willReturn($name);
+ $file->method('getSize')->willReturnCallback(static fn (): int => strlen($state->content));
+ $file->method('putContent')->willReturnCallback(static function ($newContent) use ($state): void {
+ $state->content = $newContent;
+ });
+ $file->method('getContent')->willReturnCallback(static fn (): string => $state->content);
+ return $file;
+ }
+
+ // -------------------------------------------------------------------
+ // parseCanvasTemplateData
+ // -------------------------------------------------------------------
+
+ public function testParseCanvasTemplateDataCanonicalizesBoard(): void {
+ $data = $this->service->parseCanvasTemplateData([
+ 'elements' => [['type' => 'rectangle'], 'not-an-element'],
+ 'files' => ['f1' => ['mimeType' => 'image/png'], 'f2' => null],
+ 'appState' => ['viewBackgroundColor' => '#fff', 'collaborators' => ['x'], 'selectedElementIds' => ['y']],
+ 'libraryRef' => ['scope' => 'personal', 'name' => 'Kit'],
+ 'scrollToContent' => false,
+ ]);
+
+ $this->assertSame([['type' => 'rectangle']], $data['elements']);
+ $this->assertSame(['f1' => ['mimeType' => 'image/png']], $data['files']);
+ $this->assertTrue($data['scrollToContent']);
+ $this->assertSame(['viewBackgroundColor' => '#fff'], $data['appState']);
+ $this->assertArrayNotHasKey('libraryRef', $data);
+ }
+
+ public function testParseCanvasTemplateDataAcceptsJsonString(): void {
+ $data = $this->service->parseCanvasTemplateData(json_encode(['elements' => [['type' => 'ellipse']]], JSON_THROW_ON_ERROR));
+ $this->assertCount(1, $data['elements']);
+ }
+
+ public function testParseCanvasTemplateDataRejectsEmptyElements(): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->service->parseCanvasTemplateData(['elements' => []]);
+ }
+
+ public function testParseCanvasTemplateDataRejectsInvalidJson(): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->service->parseCanvasTemplateData('not json');
+ }
+
+ public function testParseCanvasTemplateDataRejectsOversizedPayload(): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('too large');
+ $this->service->parseCanvasTemplateData(str_repeat('x', CanvasTemplateService::MAX_CANVAS_TEMPLATE_BYTES + 1));
+ }
+
+ // -------------------------------------------------------------------
+ // publishCanvasTemplate
+ // -------------------------------------------------------------------
+
+ public function testPublishTemplatePersonalWritesIntoTemplatesFolder(): void {
+ $result = $this->service->publishCanvasTemplate(self::UID, 'personal', 'Retro board', [
+ 'elements' => [['type' => 'rectangle']],
+ 'files' => [],
+ 'scrollToContent' => true,
+ ]);
+
+ $this->assertSame(['name' => 'Retro board', 'scope' => 'personal'], $result);
+ $content = json_decode($this->templatesChildren['Retro board.whiteboard']->getContent(), true);
+ $this->assertSame([['type' => 'rectangle']], $content['elements']);
+ }
+
+ public function testPublishTemplateOrgWritesIntoAppData(): void {
+ $this->service->publishCanvasTemplate(self::UID, 'org', 'Kanban', [
+ 'elements' => [['type' => 'rectangle']],
+ 'files' => [],
+ 'scrollToContent' => true,
+ ]);
+
+ $this->assertArrayHasKey('Kanban.whiteboard', $this->orgCanvasTemplates);
+ }
+
+ public function testPublishTemplateRejectsInvalidScope(): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->service->publishCanvasTemplate(self::UID, 'global', 'Name', ['elements' => [[]]]);
+ }
+
+ public function testPublishTemplateConflictsWithLibraryPointerOfSameName(): void {
+ $pointerJson = json_encode([
+ 'elements' => [],
+ 'files' => [],
+ 'scrollToContent' => true,
+ 'libraryRef' => ['scope' => 'personal', 'name' => 'Kit'],
+ ], JSON_THROW_ON_ERROR);
+ $this->templatesChildren['Kit.whiteboard'] = $this->mockWritableFile('Kit.whiteboard', $pointerJson);
+
+ try {
+ $this->service->publishCanvasTemplate(self::UID, 'personal', 'Kit', [
+ 'elements' => [['type' => 'rectangle']],
+ 'files' => [],
+ 'scrollToContent' => true,
+ ]);
+ $this->fail('Expected a conflict');
+ } catch (InvalidArgumentException $e) {
+ $this->assertSame(\OCP\AppFramework\Http::STATUS_CONFLICT, $e->getCode());
+ }
+
+ // The library pointer was not touched.
+ $this->assertSame($pointerJson, $this->templatesChildren['Kit.whiteboard']->getContent());
+ }
+
+ public function testPublishTemplateOverwritesExistingCanvasTemplate(): void {
+ $this->templatesChildren['Retro board.whiteboard'] = $this->mockWritableFile(
+ 'Retro board.whiteboard',
+ json_encode(['elements' => [['type' => 'ellipse']], 'files' => []], JSON_THROW_ON_ERROR)
+ );
+
+ $this->service->publishCanvasTemplate(self::UID, 'personal', 'Retro board', [
+ 'elements' => [['type' => 'rectangle']],
+ 'files' => [],
+ 'scrollToContent' => true,
+ ]);
+
+ $content = json_decode($this->templatesChildren['Retro board.whiteboard']->getContent(), true);
+ $this->assertSame([['type' => 'rectangle']], $content['elements']);
+ }
+
+ // -------------------------------------------------------------------
+ // listOrgCanvasTemplates / deleteOrgCanvasTemplate
+ // -------------------------------------------------------------------
+
+ public function testListOrgTemplatesSortedWithElementCounts(): void {
+ $this->orgCanvasTemplatesDirExists = true;
+ $this->orgCanvasTemplates['zebra.whiteboard'] = $this->mockWritableFile('zebra.whiteboard', json_encode(['elements' => [[], []]], JSON_THROW_ON_ERROR));
+ $this->orgCanvasTemplates['Alpha.whiteboard'] = $this->mockWritableFile('Alpha.whiteboard', json_encode(['elements' => [[]]], JSON_THROW_ON_ERROR));
+ $this->orgCanvasTemplates['broken.whiteboard'] = $this->mockWritableFile('broken.whiteboard', 'not json');
+
+ $templates = $this->service->listOrgCanvasTemplates();
+
+ $this->assertSame(['Alpha', 'broken', 'zebra'], array_column($templates, 'name'));
+ $this->assertSame([1, 0, 2], array_column($templates, 'elementCount'));
+ }
+
+ public function testListOrgTemplatesEmptyWhenFolderMissing(): void {
+ $this->assertSame([], $this->service->listOrgCanvasTemplates());
+ }
+
+ public function testDeleteOrgTemplateRemovesFile(): void {
+ $this->orgCanvasTemplatesDirExists = true;
+ $file = $this->mockWritableFile('Kanban.whiteboard', '{}');
+ $file->expects($this->once())->method('delete');
+ $this->orgCanvasTemplates['Kanban.whiteboard'] = $file;
+
+ $this->service->deleteOrgCanvasTemplate('Kanban');
+ }
+
+ public function testTemplateNameFromFileStripsExtension(): void {
+ $this->assertSame('Kanban', $this->service->canvasTemplateNameFromFile('Kanban.whiteboard'));
+ $this->assertSame('plain.txt', $this->service->canvasTemplateNameFromFile('plain.txt'));
+ }
+}
diff --git a/tests/Unit/Service/WhiteboardContentServiceTest.php b/tests/Unit/Service/WhiteboardContentServiceTest.php
new file mode 100644
index 00000000..4ba05516
--- /dev/null
+++ b/tests/Unit/Service/WhiteboardContentServiceTest.php
@@ -0,0 +1,154 @@
+createMock(File::class);
+ $file->method('getId')->willReturn(123);
+ $file->method('getContent')->willReturn(json_encode([
+ 'elements' => [
+ ['id' => 'old-element', 'type' => 'rectangle'],
+ ],
+ 'files' => [],
+ 'libraryItems' => [
+ [
+ 'id' => 'library-item-1',
+ 'elements' => [
+ ['id' => 'library-element-1', 'type' => 'ellipse'],
+ ],
+ ],
+ ],
+ 'scrollToContent' => true,
+ ], JSON_THROW_ON_ERROR));
+
+ $file->expects($this->once())
+ ->method('putContent')
+ ->with($this->callback(static function (string $content): bool {
+ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ return $data['elements'][0]['id'] === 'new-element'
+ && $data['libraryItems'][0]['id'] === 'library-item-1'
+ && !isset($data['libraryMode'])
+ && !isset($data['librarySource']);
+ }));
+
+ $service = new WhiteboardContentService($this->createMock(LoggerInterface::class));
+ $service->updateContent($file, [
+ 'data' => [
+ 'elements' => [
+ ['id' => 'new-element', 'type' => 'diamond'],
+ ],
+ 'files' => [],
+ 'scrollToContent' => true,
+ ],
+ ]);
+ }
+
+ public function testValidLibraryRefIsPersisted(): void {
+ $file = $this->mockEmptyFile();
+ $captured = $this->captureWrite($file);
+
+ $this->service()->updateContent($file, [
+ 'data' => [
+ 'elements' => [['id' => 'el', 'type' => 'rectangle']],
+ 'files' => [],
+ 'libraryRef' => ['scope' => 'org', 'name' => 'Brand kit'],
+ ],
+ ]);
+
+ // canonicalize() ksorts associative arrays, so compare order-insensitively.
+ $this->assertEquals(['scope' => 'org', 'name' => 'Brand kit'], $captured->data['libraryRef']);
+ }
+
+ public function testInvalidLibraryRefNamesAreDropped(): void {
+ $file = $this->mockEmptyFile();
+ $captured = $this->captureWrite($file);
+
+ $this->service()->updateContent($file, [
+ 'data' => [
+ 'elements' => [['id' => 'el', 'type' => 'rectangle']],
+ 'files' => [],
+ 'libraryRef' => ['scope' => 'personal', 'name' => 'a/b'],
+ ],
+ ]);
+
+ $this->assertArrayNotHasKey('libraryRef', $captured->data);
+ }
+
+ public function testNullLibraryRefRemovesStoredRef(): void {
+ $file = $this->createMock(File::class);
+ $file->method('getId')->willReturn(123);
+ $file->method('getContent')->willReturn(json_encode([
+ 'elements' => [['id' => 'el', 'type' => 'rectangle']],
+ 'files' => [],
+ 'libraryRef' => ['scope' => 'personal', 'name' => 'Kit'],
+ ], JSON_THROW_ON_ERROR));
+ $captured = $this->captureWrite($file);
+
+ $this->service()->updateContent($file, [
+ 'data' => [
+ 'elements' => [['id' => 'el2', 'type' => 'diamond']],
+ 'files' => [],
+ 'libraryRef' => null,
+ ],
+ ]);
+
+ $this->assertArrayNotHasKey('libraryRef', $captured->data);
+ }
+
+ public function testEmbeddedLibraryItemsLoseReadOnlyTag(): void {
+ $file = $this->mockEmptyFile();
+ $captured = $this->captureWrite($file);
+
+ $this->service()->updateContent($file, [
+ 'data' => [
+ 'elements' => [['id' => 'el', 'type' => 'rectangle']],
+ 'files' => [],
+ 'libraryItems' => [
+ ['id' => 'item', 'elements' => [['type' => 'ellipse']], 'libraryName' => 'Kit', 'scope' => 'org'],
+ ],
+ ],
+ ]);
+
+ $this->assertArrayNotHasKey('libraryName', $captured->data['libraryItems'][0]);
+ $this->assertArrayNotHasKey('scope', $captured->data['libraryItems'][0]);
+ }
+
+ private function service(): WhiteboardContentService {
+ return new WhiteboardContentService($this->createMock(LoggerInterface::class));
+ }
+
+ /** @return File&MockObject */
+ private function mockEmptyFile(): File {
+ $file = $this->createMock(File::class);
+ $file->method('getId')->willReturn(123);
+ $file->method('getContent')->willReturn('');
+ return $file;
+ }
+
+ /**
+ * Capture the JSON written via putContent as a decoded array on ->data.
+ *
+ * @param File&MockObject $file
+ */
+ private function captureWrite(File $file): \stdClass {
+ $captured = new \stdClass();
+ $captured->data = null;
+ $file->method('putContent')->willReturnCallback(static function (string $content) use ($captured): void {
+ $captured->data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ });
+ return $captured;
+ }
+}
diff --git a/tests/Unit/Service/WhiteboardFolderServiceTest.php b/tests/Unit/Service/WhiteboardFolderServiceTest.php
new file mode 100644
index 00000000..084a641f
--- /dev/null
+++ b/tests/Unit/Service/WhiteboardFolderServiceTest.php
@@ -0,0 +1,78 @@
+service = new WhiteboardFolderService(
+ $this->createMock(ITemplateManager::class),
+ $this->createMock(IRootFolder::class),
+ $this->createMock(IConfig::class),
+ $this->createMock(LoggerInterface::class),
+ );
+ }
+
+ public function testNormalizeScopeAcceptsKnownScopes(): void {
+ $this->assertSame('personal', $this->service->normalizeScope('personal'));
+ $this->assertSame('org', $this->service->normalizeScope('org'));
+ }
+
+ public function testNormalizeScopeRejectsUnknownScope(): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->service->normalizeScope('global');
+ }
+
+ public function testNormalizeNameTrimsWhitespace(): void {
+ $this->assertSame('My shapes', $this->service->normalizeName(' My shapes '));
+ }
+
+ #[DataProvider('invalidNameProvider')]
+ public function testNormalizeNameRejectsInvalidNames(string $name): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->service->normalizeName($name);
+ }
+
+ public static function invalidNameProvider(): array {
+ return [
+ 'empty' => [''],
+ 'whitespace only' => [' '],
+ 'dot' => ['.'],
+ 'dot dot' => ['..'],
+ 'slash' => ['a/b'],
+ 'backslash' => ['a\\b'],
+ 'control char' => ["a\x01b"],
+ 'too long' => [str_repeat('x', 251)],
+ 'windows reserved' => ['CON'],
+ 'windows reserved lowercase' => ['nul'],
+ 'windows reserved with extension' => ['com1.board'],
+ ];
+ }
+
+ public function testIsValidNameMatchesNormalizeName(): void {
+ $this->assertTrue(WhiteboardFolderService::isValidName('My shapes'));
+ $this->assertTrue(WhiteboardFolderService::isValidName('console'));
+ $this->assertFalse(WhiteboardFolderService::isValidName('CON'));
+ $this->assertFalse(WhiteboardFolderService::isValidName(''));
+ $this->assertFalse(WhiteboardFolderService::isValidName('a/b'));
+ $this->assertFalse(WhiteboardFolderService::isValidName(' padded '));
+ $this->assertFalse(WhiteboardFolderService::isValidName(str_repeat('x', 251)));
+ }
+}
diff --git a/tests/Unit/Service/WhiteboardLibraryServiceTest.php b/tests/Unit/Service/WhiteboardLibraryServiceTest.php
new file mode 100644
index 00000000..98159e0d
--- /dev/null
+++ b/tests/Unit/Service/WhiteboardLibraryServiceTest.php
@@ -0,0 +1,387 @@
+ node mock. */
+ private array $templatesChildren = [];
+ /** Children of appdata whiteboard/, dir => [name => node mock]. */
+ private array $appDataChildren = [];
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->templateManager = $this->createMock(ITemplateManager::class);
+ $this->templateManager->method('hasTemplateDirectory')->willReturn(true);
+ $this->templateManager->method('getTemplatePath')->willReturn('Templates/');
+
+ $templatesFolder = $this->mockFolder($this->templatesChildren);
+ $userFolder = $this->createMock(Folder::class);
+ $userFolder->method('get')->with('Templates/')->willReturn($templatesFolder);
+
+ // App data tree: appdata_test/whiteboard/. Lazily materialized
+ // folder mocks share their children arrays with $this->appDataChildren,
+ // so tests can seed files before a call and inspect writes after it.
+ $appDataDirs = [];
+ $makeDir = function (string $dir) use (&$appDataDirs): Folder {
+ if (!isset($appDataDirs[$dir])) {
+ if (!isset($this->appDataChildren[$dir])) {
+ $this->appDataChildren[$dir] = [];
+ }
+ $appDataDirs[$dir] = $this->mockFolder($this->appDataChildren[$dir]);
+ }
+ return $appDataDirs[$dir];
+ };
+
+ $whiteboardFolder = $this->createMock(Folder::class);
+ $whiteboardFolder->method('nodeExists')->willReturnCallback(fn (string $dir): bool => isset($this->appDataChildren[$dir]));
+ $whiteboardFolder->method('get')->willReturnCallback($makeDir);
+ $whiteboardFolder->method('newFolder')->willReturnCallback($makeDir);
+
+ $appDataRoot = $this->createMock(Folder::class);
+ $appDataRoot->method('nodeExists')->with('whiteboard')->willReturn(true);
+ $appDataRoot->method('get')->with('whiteboard')->willReturn($whiteboardFolder);
+
+ $this->rootFolder = $this->createMock(IRootFolder::class);
+ $this->rootFolder->method('getUserFolder')->with(self::UID)->willReturn($userFolder);
+ $this->rootFolder->method('nodeExists')->willReturnCallback(static fn (string $n): bool => $n === 'appdata_test');
+ $this->rootFolder->method('get')->willReturnCallback(function (string $path) use ($appDataRoot, $makeDir) {
+ if ($path === 'appdata_test') {
+ return $appDataRoot;
+ }
+ if (preg_match('#^appdata_test/whiteboard/(.+)$#', $path, $m) === 1 && isset($this->appDataChildren[$m[1]])) {
+ return $makeDir($m[1]);
+ }
+ throw new \OCP\Files\NotFoundException($path);
+ });
+
+ $config = $this->createMock(IConfig::class);
+ $config->method('getSystemValueString')->with('instanceid', '')->willReturn('test');
+
+ $logger = $this->createMock(LoggerInterface::class);
+ $folders = new WhiteboardFolderService($this->templateManager, $this->rootFolder, $config, $logger, static fn (): bool => true);
+ $this->service = new WhiteboardLibraryService($folders, $config, $logger);
+ }
+
+ // -------------------------------------------------------------------
+ // helpers
+ // -------------------------------------------------------------------
+
+ /**
+ * Folder mock backed by a by-reference children array. newFile() and
+ * newFolder() mutate it, so created nodes are visible to later calls.
+ *
+ * @return Folder&MockObject
+ */
+ private function mockFolder(array &$children): Folder {
+ $folder = $this->createMock(Folder::class);
+ $folder->method('nodeExists')->willReturnCallback(static function (string $name) use (&$children): bool {
+ return isset($children[$name]);
+ });
+ $folder->method('get')->willReturnCallback(static function (string $name) use (&$children) {
+ if (!isset($children[$name])) {
+ throw new \OCP\Files\NotFoundException($name);
+ }
+ return $children[$name];
+ });
+ $folder->method('getDirectoryListing')->willReturnCallback(static function () use (&$children): array {
+ return array_values($children);
+ });
+ $folder->method('newFile')->willReturnCallback(function (string $name) use (&$children) {
+ $children[$name] = $this->mockWritableFile($name);
+ return $children[$name];
+ });
+ $folder->method('newFolder')->willReturnCallback(function (string $name) use (&$children) {
+ $grandChildren = [];
+ $children[$name] = $this->mockFolder($grandChildren);
+ return $children[$name];
+ });
+ return $folder;
+ }
+
+ /** @return File&MockObject */
+ private function mockWritableFile(string $name, string $content = ''): File {
+ $state = new \stdClass();
+ $state->content = $content;
+ $file = $this->createMock(File::class);
+ $file->method('getName')->willReturn($name);
+ $file->method('putContent')->willReturnCallback(static function ($newContent) use ($state): void {
+ $state->content = $newContent;
+ });
+ $file->method('getContent')->willReturnCallback(static fn (): string => $state->content);
+ return $file;
+ }
+
+ private function libraryJson(array $items): string {
+ return json_encode(['type' => 'excalidrawlib', 'version' => 2, 'libraryItems' => $items], JSON_THROW_ON_ERROR);
+ }
+
+ private function pointerJson(string $scope, string $name): string {
+ return json_encode([
+ 'elements' => [],
+ 'files' => [],
+ 'scrollToContent' => true,
+ 'libraryRef' => ['scope' => $scope, 'name' => $name],
+ ], JSON_THROW_ON_ERROR);
+ }
+
+ // -------------------------------------------------------------------
+ // saveLibrary
+ // -------------------------------------------------------------------
+
+ public function testSaveLibraryPersonalWritesItemsAndPointer(): void {
+ $result = $this->service->saveLibrary(self::UID, 'personal', ' My shapes ', [
+ ['elements' => [['type' => 'rectangle']], 'libraryName' => 'stale-tag'],
+ ]);
+
+ $this->assertSame(['name' => 'My shapes', 'scope' => 'personal'], $result);
+
+ $libraryDir = $this->templatesChildren['.whiteboard-libraries'];
+ $itemsFile = $libraryDir->get('My shapes.excalidrawlib');
+ $items = json_decode($itemsFile->getContent(), true)['libraryItems'];
+ $this->assertCount(1, $items);
+ $this->assertArrayNotHasKey('libraryName', $items[0]);
+
+ $pointer = json_decode($this->templatesChildren['My shapes.whiteboard']->getContent(), true);
+ $this->assertSame(['scope' => 'personal', 'name' => 'My shapes'], $pointer['libraryRef']);
+ $this->assertSame([], $pointer['elements']);
+ }
+
+ public function testSaveLibraryOrgWritesToAppData(): void {
+ $this->service->saveLibrary(self::UID, 'org', 'Brand kit', [
+ ['elements' => [['type' => 'ellipse']]],
+ ]);
+
+ $this->assertArrayHasKey('Brand kit.excalidrawlib', $this->appDataChildren['libraries']);
+ $pointer = json_decode($this->appDataChildren['library-pointers']['Brand kit.whiteboard']->getContent(), true);
+ $this->assertSame(['scope' => 'org', 'name' => 'Brand kit'], $pointer['libraryRef']);
+ }
+
+ public function testSaveLibraryRejectsEmptyItems(): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->service->saveLibrary(self::UID, 'personal', 'Empty', [
+ ['elements' => []],
+ 'not-an-item',
+ ]);
+ }
+
+ public function testSaveLibraryRejectsInvalidScope(): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->service->saveLibrary(self::UID, 'global', 'Name', [['elements' => [['type' => 'rectangle']]]]);
+ }
+
+ public function testSaveLibraryRejectsImageItems(): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('image items');
+ $this->service->saveLibrary(self::UID, 'personal', 'Pics', [
+ ['elements' => [['type' => 'rectangle'], ['type' => 'image']]],
+ ]);
+ }
+
+ public function testSaveLibraryConflictsWithCanvasTemplateOfSameName(): void {
+ $this->templatesChildren['Kit.whiteboard'] = $this->mockWritableFile(
+ 'Kit.whiteboard',
+ json_encode(['elements' => [['type' => 'rectangle']], 'files' => []], JSON_THROW_ON_ERROR)
+ );
+
+ try {
+ $this->service->saveLibrary(self::UID, 'personal', 'Kit', [['elements' => [['type' => 'ellipse']]]]);
+ $this->fail('Expected a conflict');
+ } catch (InvalidArgumentException $e) {
+ $this->assertSame(\OCP\AppFramework\Http::STATUS_CONFLICT, $e->getCode());
+ }
+
+ // The conflict is detected before the items file is written — no orphan.
+ $this->assertArrayNotHasKey('.whiteboard-libraries', $this->templatesChildren);
+ // The canvas template was not touched.
+ $content = json_decode($this->templatesChildren['Kit.whiteboard']->getContent(), true);
+ $this->assertSame([['type' => 'rectangle']], $content['elements']);
+ }
+
+ public function testSaveLibraryOverwritesExistingPointer(): void {
+ $this->templatesChildren['Kit.whiteboard'] = $this->mockWritableFile('Kit.whiteboard', $this->pointerJson('personal', 'Kit'));
+
+ $result = $this->service->saveLibrary(self::UID, 'personal', 'Kit', [['elements' => [['type' => 'ellipse']]]]);
+
+ $this->assertSame(['name' => 'Kit', 'scope' => 'personal'], $result);
+ $pointer = json_decode($this->templatesChildren['Kit.whiteboard']->getContent(), true);
+ $this->assertSame(['scope' => 'personal', 'name' => 'Kit'], $pointer['libraryRef']);
+ }
+
+ // -------------------------------------------------------------------
+ // listLibraries / resolveLibrary
+ // -------------------------------------------------------------------
+
+ public function testListLibrariesReturnsBothScopesSorted(): void {
+ $personalChildren = [];
+ $personalDir = $this->mockFolder($personalChildren);
+ $personalChildren['zebra.excalidrawlib'] = $this->mockWritableFile('zebra.excalidrawlib', $this->libraryJson([['elements' => [[]]]]));
+ $personalChildren['Alpha.excalidrawlib'] = $this->mockWritableFile('Alpha.excalidrawlib', $this->libraryJson([['elements' => [[]]], ['elements' => [[]]]]));
+ $personalChildren['broken.excalidrawlib'] = $this->mockWritableFile('broken.excalidrawlib', 'not json');
+ $this->templatesChildren['.whiteboard-libraries'] = $personalDir;
+
+ $this->appDataChildren['libraries'] = [
+ 'Org kit.excalidrawlib' => $this->mockWritableFile('Org kit.excalidrawlib', $this->libraryJson([['elements' => [[]]]])),
+ ];
+
+ $result = $this->service->listLibraries(self::UID);
+
+ $this->assertSame(
+ [['name' => 'Alpha', 'itemCount' => 2], ['name' => 'broken', 'itemCount' => 0], ['name' => 'zebra', 'itemCount' => 1]],
+ $result['personal']
+ );
+ $this->assertSame([['name' => 'Org kit', 'itemCount' => 1]], $result['org']);
+ }
+
+ public function testResolveLibraryTagsItemsWithLibraryName(): void {
+ $personalChildren = [];
+ $personalDir = $this->mockFolder($personalChildren);
+ $personalChildren['Kit.excalidrawlib'] = $this->mockWritableFile('Kit.excalidrawlib', $this->libraryJson([
+ ['id' => 'src1', 'elements' => [['type' => 'rectangle']], 'filename' => 'x', 'writable' => true],
+ ]));
+ $this->templatesChildren['.whiteboard-libraries'] = $personalDir;
+
+ $items = $this->service->resolveLibrary(self::UID, 'personal', 'Kit');
+
+ $this->assertCount(1, $items);
+ $this->assertSame('Kit', $items[0]['libraryName']);
+ // Namespaced so resolved items never collide with their "My library" originals.
+ $this->assertSame('personal:Kit:src1', $items[0]['id']);
+ $this->assertArrayNotHasKey('filename', $items[0]);
+ $this->assertArrayNotHasKey('writable', $items[0]);
+ }
+
+ public function testResolveLibraryReturnsEmptyWhenMissing(): void {
+ $this->assertSame([], $this->service->resolveLibrary(self::UID, 'org', 'Nope'));
+ $this->assertSame([], $this->service->resolveLibrary(self::UID, 'personal', 'Nope'));
+ }
+
+ public function testListLibrariesEmptyWithoutAnyFolders(): void {
+ $this->assertSame(['personal' => [], 'org' => []], $this->service->listLibraries(self::UID));
+ }
+
+ public function testListLibrariesBlocksSharedSessionsFromOrg(): void {
+ $this->appDataChildren['libraries'] = [
+ 'Org kit.excalidrawlib' => $this->mockWritableFile('Org kit.excalidrawlib', $this->libraryJson([['elements' => [[]]]])),
+ ];
+
+ $this->assertSame(['personal' => [], 'org' => []], $this->service->listLibraries('shared_token123'));
+ }
+
+ public function testResolveLibraryBlocksSharedSessionsFromOrg(): void {
+ $this->appDataChildren['libraries'] = [
+ 'Org kit.excalidrawlib' => $this->mockWritableFile('Org kit.excalidrawlib', $this->libraryJson([['elements' => [['type' => 'rectangle']]]])),
+ ];
+
+ $this->assertSame([], $this->service->resolveLibrary('shared_token123', 'org', 'Org kit'));
+ }
+
+ // -------------------------------------------------------------------
+ // deleteLibrary
+ // -------------------------------------------------------------------
+
+ public function testDeleteLibraryRemovesItemsAndPointer(): void {
+ $personalChildren = [];
+ $personalDir = $this->mockFolder($personalChildren);
+ $itemsFile = $this->mockWritableFile('Kit.excalidrawlib', $this->libraryJson([['elements' => [[]]]]));
+ $itemsFile->expects($this->once())->method('delete');
+ $personalChildren['Kit.excalidrawlib'] = $itemsFile;
+ $this->templatesChildren['.whiteboard-libraries'] = $personalDir;
+
+ $pointer = $this->mockWritableFile('Kit.whiteboard', $this->pointerJson('personal', 'Kit'));
+ $pointer->expects($this->once())->method('delete');
+ $this->templatesChildren['Kit.whiteboard'] = $pointer;
+
+ $this->service->deleteLibrary(self::UID, 'personal', 'Kit');
+ }
+
+ public function testDeleteLibraryKeepsCanvasTemplateOfSameName(): void {
+ $canvasTemplate = $this->mockWritableFile(
+ 'Kit.whiteboard',
+ json_encode(['elements' => [['type' => 'rectangle']], 'files' => []], JSON_THROW_ON_ERROR)
+ );
+ $canvasTemplate->expects($this->never())->method('delete');
+ $this->templatesChildren['Kit.whiteboard'] = $canvasTemplate;
+
+ $this->service->deleteLibrary(self::UID, 'personal', 'Kit');
+ }
+
+ // -------------------------------------------------------------------
+ // parseLibraryFile
+ // -------------------------------------------------------------------
+
+ public function testParseLibraryFileReadsV2(): void {
+ $items = $this->service->parseLibraryFile($this->libraryJson([['elements' => [['type' => 'rectangle']]]]));
+ $this->assertCount(1, $items);
+ }
+
+ public function testParseLibraryFileReadsV1(): void {
+ $content = json_encode(['type' => 'excalidrawlib', 'version' => 1, 'library' => [[['type' => 'rectangle']]]], JSON_THROW_ON_ERROR);
+ $items = $this->service->parseLibraryFile($content);
+ $this->assertCount(1, $items);
+ $this->assertSame([['type' => 'rectangle']], $items[0]['elements']);
+ }
+
+ public function testParseLibraryFileRejectsInvalidJson(): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->service->parseLibraryFile('not json');
+ }
+
+ public function testParseLibraryFileRejectsEmptyLibrary(): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->service->parseLibraryFile($this->libraryJson([]));
+ }
+
+ public function testParseLibraryFileRejectsImageItems(): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('image items');
+ $this->service->parseLibraryFile($this->libraryJson([
+ ['elements' => [['type' => 'image']]],
+ ]));
+ }
+
+ // -------------------------------------------------------------------
+ // pointer helpers
+ // -------------------------------------------------------------------
+
+ public function testLibraryNameFromPointerStripsExtension(): void {
+ $this->assertSame('Kit', $this->service->libraryNameFromPointer('Kit.whiteboard'));
+ $this->assertSame('other.txt', $this->service->libraryNameFromPointer('other.txt'));
+ }
+
+ public function testGetOrgLibraryPointerFilesListsOnlyWhiteboardFiles(): void {
+ $this->appDataChildren['library-pointers'] = [
+ 'Kit.whiteboard' => $this->mockWritableFile('Kit.whiteboard'),
+ 'readme.txt' => $this->mockWritableFile('readme.txt'),
+ ];
+
+ $files = $this->service->getOrgLibraryPointerFiles();
+ $this->assertCount(1, $files);
+ $this->assertSame('Kit.whiteboard', $files[0]->getName());
+ }
+}
diff --git a/vite.config.ts b/vite.config.ts
index 459a2af6..6ace971c 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -13,6 +13,7 @@ const AppConfig = createAppConfig({
main: resolve(join('src', 'main.ts')),
settings: resolve(join('src', 'admin.ts')),
personal: resolve(join('src', 'personal.ts')),
+ files: resolve(join('src', 'files.ts')),
}, {
config: defineConfig({
resolve: {