diff --git a/appinfo/routes.php b/appinfo/routes.php
index 3e8e0cb3..9dae8c34 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -23,6 +23,8 @@
['name' => 'Whiteboard#getLib', 'url' => 'library', 'verb' => 'GET'],
/** @see WhiteboardController::updateLib() */
['name' => 'Whiteboard#updateLib', 'url' => 'library', 'verb' => 'PUT'],
+ /** @see WhiteboardController::saveLibTemplate() */
+ ['name' => 'Whiteboard#saveLibTemplate', 'url' => 'library/template', 'verb' => 'POST'],
/** @see WhiteboardController::update() */
['name' => 'Whiteboard#update', 'url' => '{fileId}', 'verb' => 'PUT'],
/** @see WhiteboardController::show() */
@@ -33,6 +35,12 @@
['name' => 'Recording#upload', 'url' => 'recording/{fileId}/upload', 'verb' => 'POST'],
/** @see SettingsController::update() */
['name' => 'Settings#update', 'url' => 'settings', 'verb' => 'POST'],
+ /** @see SettingsController::listGlobalLibraryTemplates() */
+ ['name' => 'Settings#listGlobalLibraryTemplates', 'url' => 'settings/global-library', 'verb' => 'GET'],
+ /** @see SettingsController::uploadGlobalLibraryTemplate() */
+ ['name' => 'Settings#uploadGlobalLibraryTemplate', 'url' => 'settings/global-library', 'verb' => 'POST'],
+ /** @see SettingsController::deleteGlobalLibraryTemplate() */
+ ['name' => 'Settings#deleteGlobalLibraryTemplate', 'url' => 'settings/global-library/{templateName}', 'verb' => 'DELETE'],
/** @see SettingsController::updatePersonal() */
['name' => 'Settings#updatePersonal', 'url' => 'settings/personal', 'verb' => 'POST'],
]
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 3ca22b1b..72823751 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -14,14 +14,17 @@
use OCA\Viewer\Event\LoadViewer;
use OCA\Whiteboard\Listener\AddContentSecurityPolicyListener;
use OCA\Whiteboard\Listener\BeforeTemplateRenderedListener;
+use OCA\Whiteboard\Listener\FileCreatedFromTemplateListener;
use OCA\Whiteboard\Listener\LoadTextEditorListener;
use OCA\Whiteboard\Listener\LoadViewerListener;
use OCA\Whiteboard\Listener\RegisterTemplateCreatorListener;
use OCA\Whiteboard\Settings\SetupCheck;
+use OCA\Whiteboard\Template\GlobalLibraryTemplateProvider;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
+use OCP\Files\Template\FileCreatedFromTemplateEvent;
use OCP\Files\Template\ITemplateManager;
use OCP\Files\Template\RegisterTemplateCreatorEvent;
use OCP\IL10N;
@@ -47,7 +50,12 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(LoadViewer::class, LoadViewerListener::class);
$context->registerEventListener(LoadViewer::class, LoadTextEditorListener::class);
$context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateCreatorListener::class);
+ $context->registerEventListener(FileCreatedFromTemplateEvent::class, FileCreatedFromTemplateListener::class);
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
+ [$major] = Util::getVersion();
+ if ($major >= 30) {
+ $context->registerTemplateProvider(GlobalLibraryTemplateProvider::class);
+ }
$context->registerSetupCheck(SetupCheck::class);
}
diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php
index 3b46cc88..aeb9887b 100644
--- a/lib/Controller/SettingsController.php
+++ b/lib/Controller/SettingsController.php
@@ -13,8 +13,10 @@
use OCA\Whiteboard\Service\ConfigService;
use OCA\Whiteboard\Service\ExceptionService;
use OCA\Whiteboard\Service\JWTService;
+use OCA\Whiteboard\Service\WhiteboardLibraryService;
use OCA\Whiteboard\Settings\SetupCheck;
use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
use OCP\IUserSession;
@@ -31,6 +33,7 @@ public function __construct(
private ConfigService $configService,
private SetupCheck $setupCheck,
private IUserSession $userSession,
+ private WhiteboardLibraryService $libraryService,
) {
parent::__construct('whiteboard', $request);
}
@@ -91,4 +94,44 @@ public function updatePersonal(): DataResponse {
return $this->exceptionService->handleException($e);
}
}
+
+ public function listGlobalLibraryTemplates(): DataResponse {
+ try {
+ return new DataResponse($this->libraryService->getGlobalTemplateMetadata());
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
+ public function uploadGlobalLibraryTemplate(): DataResponse {
+ try {
+ $uploadedFile = $this->request->getUploadedFile('file');
+ if (!is_array($uploadedFile) || !isset($uploadedFile['tmp_name'], $uploadedFile['name'])) {
+ throw new Exception('No library preset uploaded', Http::STATUS_BAD_REQUEST);
+ }
+ if (($uploadedFile['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
+ throw new Exception('Library preset upload failed', Http::STATUS_BAD_REQUEST);
+ }
+
+ $content = file_get_contents($uploadedFile['tmp_name']);
+ if ($content === false) {
+ throw new Exception('Failed to read uploaded library preset', Http::STATUS_BAD_REQUEST);
+ }
+
+ return new DataResponse([
+ 'template' => $this->libraryService->saveGlobalTemplateFromUpload($uploadedFile['name'], $content),
+ ], Http::STATUS_CREATED);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
+ public function deleteGlobalLibraryTemplate(string $templateName): DataResponse {
+ try {
+ $this->libraryService->deleteGlobalTemplate($templateName);
+ return new DataResponse(['status' => 'success']);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
}
diff --git a/lib/Controller/WhiteboardController.php b/lib/Controller/WhiteboardController.php
index 9f720c09..4cc7eff0 100644
--- a/lib/Controller/WhiteboardController.php
+++ b/lib/Controller/WhiteboardController.php
@@ -10,6 +10,7 @@
namespace OCA\Whiteboard\Controller;
use Exception;
+use InvalidArgumentException;
use OCA\Whiteboard\Exception\InvalidUserException;
use OCA\Whiteboard\Exception\UnauthorizedException;
use OCA\Whiteboard\Service\Authentication\GetUserFromIdServiceFactory;
@@ -20,6 +21,7 @@
use OCA\Whiteboard\Service\WhiteboardContentService;
use OCA\Whiteboard\Service\WhiteboardLibraryService;
use OCP\AppFramework\ApiController;
+use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
@@ -110,8 +112,8 @@ public function update(int $fileId, array $data): DataResponse {
public function getLib(): DataResponse {
try {
$jwt = $this->getJwtFromRequest();
- $this->jwtService->getUserIdFromJWT($jwt);
- $data = $this->libraryService->getUserLib();
+ $userId = $this->jwtService->getUserIdFromJWT($jwt);
+ $data = $this->libraryService->getUserLib($userId);
return new DataResponse(['data' => $data]);
} catch (Exception $e) {
@@ -135,6 +137,28 @@ public function updateLib(): DataResponse {
}
}
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ #[PublicPage]
+ public function saveLibTemplate(): DataResponse {
+ try {
+ $jwt = $this->getJwtFromRequest();
+ $userId = $this->jwtService->getUserIdFromJWT($jwt);
+ $templateName = $this->request->getParam('templateName', '');
+ $items = $this->request->getParam('items', []);
+
+ if (!is_string($templateName) || !is_array($items)) {
+ throw new InvalidArgumentException('Invalid library preset payload', Http::STATUS_BAD_REQUEST);
+ }
+
+ $template = $this->libraryService->saveUserTemplate($userId, $templateName, $items);
+
+ return new DataResponse(['status' => 'success', 'template' => $template]);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
private function getJwtFromRequest(): string {
$authHeader = $this->request->getHeader('Authorization');
if (sscanf($authHeader, 'Bearer %s', $jwt) !== 1) {
diff --git a/lib/Listener/FileCreatedFromTemplateListener.php b/lib/Listener/FileCreatedFromTemplateListener.php
new file mode 100644
index 00000000..0211566e
--- /dev/null
+++ b/lib/Listener/FileCreatedFromTemplateListener.php
@@ -0,0 +1,81 @@
+ */
+/**
+ * @psalm-suppress MissingTemplateParam
+ */
+final class FileCreatedFromTemplateListener implements IEventListener {
+ private const LIB_EXTENSION = '.excalidrawlib';
+ private const WHITEBOARD_EXTENSION = '.whiteboard';
+ private const GLOBAL_TEMPLATE_DIR = '/whiteboard/global-libraries/';
+
+ /**
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function __construct(
+ private WhiteboardLibraryService $libraryService,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ #[\Override]
+ public function handle(Event $event): void {
+ if (!($event instanceof FileCreatedFromTemplateEvent)) {
+ return;
+ }
+
+ $template = $event->getTemplate();
+ $target = $event->getTarget();
+ if (!($template instanceof File)) {
+ return;
+ }
+
+ if (!$this->isOrganizationLibraryTemplate($template) || !$this->isWhiteboardTarget($target)) {
+ return;
+ }
+
+ $libraryItems = $this->libraryService->parseLibraryContent($template->getContent());
+ if ($libraryItems === null) {
+ return;
+ }
+
+ try {
+ $target->putContent(json_encode([
+ 'elements' => [],
+ 'files' => [],
+ 'libraryItems' => $libraryItems,
+ 'scrollToContent' => true,
+ ], JSON_THROW_ON_ERROR));
+ } catch (\Throwable $e) {
+ $this->logger->warning('Failed to normalize whiteboard created from library preset', [
+ 'app' => 'whiteboard',
+ 'template' => $template->getPath(),
+ 'target' => $target->getPath(),
+ 'exception' => $e,
+ ]);
+ }
+ }
+
+ private function isOrganizationLibraryTemplate(File $file): bool {
+ return str_ends_with(strtolower($file->getName()), self::LIB_EXTENSION)
+ && str_contains($file->getPath(), self::GLOBAL_TEMPLATE_DIR);
+ }
+
+ private function isWhiteboardTarget(File $file): bool {
+ return str_ends_with(strtolower($file->getName()), self::WHITEBOARD_EXTENSION);
+ }
+}
diff --git a/lib/Service/WhiteboardContentService.php b/lib/Service/WhiteboardContentService.php
index 5ba88d87..a06bd576 100644
--- a/lib/Service/WhiteboardContentService.php
+++ b/lib/Service/WhiteboardContentService.php
@@ -168,6 +168,10 @@ private function normalizeIncomingData(array $incoming): array {
: [];
}
+ if (array_key_exists('libraryItems', $incoming) && is_array($incoming['libraryItems'])) {
+ $normalized['libraryItems'] = $this->sanitizeLibraryItems($incoming['libraryItems']);
+ }
+
if (array_key_exists('appState', $incoming) && is_array($incoming['appState'])) {
$normalized['appState'] = $this->sanitizeAppState($incoming['appState']);
}
@@ -183,6 +187,10 @@ private function normalizeIncomingData(array $incoming): array {
* @param array $payload
*/
private function isEffectivelyEmptyPayload(array $payload): bool {
+ if (array_key_exists('libraryItems', $payload)) {
+ return false;
+ }
+
$hasFiles = array_key_exists('files', $payload)
&& is_array($payload['files'])
&& !empty($payload['files']);
@@ -212,7 +220,7 @@ private function isEffectivelyEmptyPayload(array $payload): bool {
}
foreach ($payload as $key => $_value) {
- if (!in_array($key, ['elements', 'files', 'appState', 'scrollToContent'], true)) {
+ if (!in_array($key, ['elements', 'files', 'libraryItems', 'appState', 'scrollToContent'], true)) {
return false;
}
}
@@ -244,6 +252,10 @@ private function normalizeStoredData(array $stored): array {
$normalized['files'] = $this->sanitizeFiles($stored['files']);
}
+ if (array_key_exists('libraryItems', $stored) && is_array($stored['libraryItems'])) {
+ $normalized['libraryItems'] = $this->sanitizeLibraryItems($stored['libraryItems']);
+ }
+
if (array_key_exists('appState', $stored) && is_array($stored['appState'])) {
$normalized['appState'] = $this->sanitizeAppState($stored['appState']);
} elseif (array_key_exists('appState', $stored) && $stored['appState'] === null) {
@@ -274,6 +286,10 @@ private function mergeData(array $current, array $incoming): array {
$merged['files'] = $incoming['files'];
}
+ if (array_key_exists('libraryItems', $incoming)) {
+ $merged['libraryItems'] = $incoming['libraryItems'];
+ }
+
if (array_key_exists('appState', $incoming)) {
if ($incoming['appState'] === null) {
unset($merged['appState']);
@@ -331,6 +347,27 @@ private function sanitizeFiles(array $files): array {
return $sanitized;
}
+ /**
+ * @param array $items
+ *
+ * @return array
+ */
+ private function sanitizeLibraryItems(array $items): array {
+ $sanitized = [];
+
+ foreach ($items as $item) {
+ if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements']) || count($item['elements']) === 0) {
+ continue;
+ }
+
+ unset($item['templateName'], $item['scope'], $item['filename'], $item['basename']);
+ $item['elements'] = array_values($item['elements']);
+ $sanitized[] = $item;
+ }
+
+ return $sanitized;
+ }
+
/**
* @param array $appState
*
diff --git a/lib/Service/WhiteboardLibraryService.php b/lib/Service/WhiteboardLibraryService.php
index de27f938..d22dde03 100644
--- a/lib/Service/WhiteboardLibraryService.php
+++ b/lib/Service/WhiteboardLibraryService.php
@@ -9,8 +9,9 @@
namespace OCA\Whiteboard\Service;
+use InvalidArgumentException;
use JsonException;
-use OCA\Whiteboard\AppInfo\Application;
+use OCP\AppFramework\Http;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\GenericFileException;
@@ -18,7 +19,10 @@
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\Template\ITemplateManager;
+use OCP\IConfig;
use OCP\Lock\LockedException;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
/**
* @psalm-suppress UndefinedDocblockClass
@@ -26,47 +30,56 @@
* @psalm-suppress MissingDependency
*/
final class WhiteboardLibraryService {
+ private const LIB_EXTENSION = '.excalidrawlib';
+ private const GLOBAL_TEMPLATE_DISPLAY_PREFIX = 'Org library preset - ';
+ private const GLOBAL_TEMPLATE_ID_PREFIX = 'organization-preset:';
+ private const GLOBAL_TEMPLATE_DIR = 'global-libraries';
+ private const MAX_FILENAME_BYTES = 250;
+
public function __construct(
private ITemplateManager $templateManager,
private IRootFolder $rootFolder,
+ private IConfig $config,
+ private LoggerInterface $logger,
) {
}
/**
* @throws NotPermittedException
+ * @throws NotFoundException
* @throws GenericFileException
* @throws LockedException
* @throws JsonException
*/
- public function getUserLib(): array {
- // Get the .excalidrawlib files from the /Templates directory
- $availableFileCreators = $this->templateManager->listTemplates();
- $templates = [];
+ public function getUserLib(string $uid): array {
+ if (str_starts_with($uid, 'shared_')) {
+ return [];
+ }
+
+ $templateFolder = $this->getUserTemplateFolder($uid);
$libs = [];
- foreach ($availableFileCreators as $fileCreator) {
- if ($fileCreator['app'] !== Application::APP_ID) {
+ foreach ($templateFolder->getDirectoryListing() as $node) {
+ if (!$node instanceof File || !$this->isLibraryFileName($node->getName()) || str_starts_with($node->getName(), self::GLOBAL_TEMPLATE_DISPLAY_PREFIX)) {
continue;
}
- $templates = $fileCreator['templates'];
- break;
- }
-
- foreach ($templates as $template) {
- $templateDetails = $template->jsonSerialize();
-
- if (str_ends_with($templateDetails['basename'], '.excalidrawlib')) {
- $fileId = $templateDetails['fileid'];
- $file = $this->rootFolder->getFirstNodeById($fileId);
- if (!$file instanceof File) {
- continue;
- }
-
- $lib = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR);
- $lib['basename'] = $templateDetails['basename'];
- $libs[] = $lib;
+ $libraryItems = $this->parseLibraryContent($node->getContent());
+ if ($libraryItems === null) {
+ $this->logger->warning('Skipping malformed whiteboard library preset', [
+ 'uid' => $uid,
+ 'file' => $node->getName(),
+ ]);
+ continue;
}
+
+ $libs[] = [
+ 'type' => 'excalidrawlib',
+ 'version' => 2,
+ 'libraryItems' => $libraryItems,
+ 'basename' => $node->getName(),
+ 'filename' => $node->getName(),
+ ];
}
return $libs;
@@ -80,19 +93,11 @@ public function getUserLib(): array {
* @throws JsonException
*/
public function updateUserLib(string $uid, array $items): void {
- // Check if the user has a Templates folder, if not create one
- if (!$this->templateManager->hasTemplateDirectory()) {
- $this->templateManager->initializeTemplateDirectory(null, $uid, false);
+ if (str_starts_with($uid, 'shared_')) {
+ return;
}
- // Update the .excalidrawlib files in the Templates directory
- $userFolder = $this->rootFolder->getUserFolder($uid);
- $templatesPath = $this->templateManager->getTemplatePath();
- $templatesFolder = $userFolder->get($templatesPath);
-
- if (!$templatesFolder instanceof Folder) {
- throw new NotFoundException('Templates folder not found for user: ' . $uid);
- }
+ $templatesFolder = $this->getUserTemplateFolder($uid);
$files = [
'personal.excalidrawlib' => [
@@ -132,4 +137,461 @@ public function updateUserLib(string $uid, array $items): void {
$file->putContent(json_encode($fileData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
}
}
+
+ /**
+ * @throws NotPermittedException
+ * @throws NotFoundException
+ * @throws GenericFileException
+ * @throws LockedException
+ * @throws JsonException
+ */
+ public function saveUserTemplate(string $uid, string $templateName, array $items): array {
+ if (str_starts_with($uid, 'shared_')) {
+ throw new InvalidArgumentException('Shared users cannot save library presets', Http::STATUS_BAD_REQUEST);
+ }
+
+ $templateFolder = $this->getUserTemplateFolder($uid);
+ $normalizedName = $this->normalizeTemplateName($templateName);
+ $currentFiles = $this->listUserLibraryFiles($templateFolder);
+ $caseKey = $this->toCaseKey($normalizedName);
+
+ if (isset($currentFiles[$caseKey])) {
+ throw new RuntimeException('Library preset already exists', Http::STATUS_CONFLICT);
+ }
+
+ $normalizedItems = $this->normalizeLibraryItems($items);
+ if ($normalizedItems === []) {
+ throw new InvalidArgumentException('Library preset must contain at least one item', Http::STATUS_BAD_REQUEST);
+ }
+ if ($this->containsImageElement($normalizedItems)) {
+ throw new InvalidArgumentException('This library contains image items that cannot be imported by Whiteboard yet.', Http::STATUS_BAD_REQUEST);
+ }
+
+ $this->writeUserTemplate($templateFolder, $normalizedName, $normalizedItems);
+
+ return [
+ 'templateName' => $normalizedName,
+ 'itemCount' => count($normalizedItems),
+ ];
+ }
+
+ /**
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ * @throws LockedException
+ */
+ public function getGlobalTemplateMetadata(): array {
+ return [
+ 'templates' => array_map(static fn (array $template): array => [
+ 'templateName' => $template['templateName'],
+ 'itemCount' => count($template['items']),
+ ], $this->listGlobalTemplates()['templates']),
+ ];
+ }
+
+ /**
+ * @return array
+ *
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ * @throws LockedException
+ */
+ public function getGlobalTemplateFiles(): array {
+ return $this->listGlobalTemplates()['files'];
+ }
+
+ /**
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ * @throws LockedException
+ * @throws NotFoundException
+ */
+ public function getGlobalTemplateFile(string $templateId): File {
+ $normalizedName = $this->normalizeGlobalTemplateId($templateId);
+ $files = $this->getGlobalTemplateFiles();
+ $file = $files[$this->toCaseKey($normalizedName)] ?? null;
+ if (!$file instanceof File) {
+ throw new NotFoundException('Organization library preset not found');
+ }
+ return $file;
+ }
+
+ public function getGlobalTemplateId(string $templateName): string {
+ return self::GLOBAL_TEMPLATE_ID_PREFIX . $this->toCaseKey($templateName);
+ }
+
+ public function getGlobalTemplateNameFromFileName(string $fileName): string {
+ $name = $this->stripLibraryExtension($fileName);
+ if (str_starts_with($name, self::GLOBAL_TEMPLATE_DISPLAY_PREFIX)) {
+ $name = substr($name, strlen(self::GLOBAL_TEMPLATE_DISPLAY_PREFIX));
+ }
+ return $name;
+ }
+
+ /**
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ * @throws LockedException
+ * @throws JsonException
+ */
+ public function saveGlobalTemplateFromUpload(string $fileName, string $content): array {
+ if (!$this->isLibraryFileName($fileName)) {
+ throw new InvalidArgumentException('Upload an .excalidrawlib file', Http::STATUS_BAD_REQUEST);
+ }
+
+ $templateName = $this->normalizeTemplateName($fileName);
+ $caseKey = $this->toCaseKey($templateName);
+ $current = $this->listGlobalTemplates();
+
+ if (isset($current['loadedFiles'][$caseKey])) {
+ throw new RuntimeException('A library preset with this name already exists. Rename the file and upload it again.', Http::STATUS_CONFLICT);
+ }
+
+ $items = $this->parseLibraryContent($content);
+ if ($items === null) {
+ throw new InvalidArgumentException('This is not a valid Excalidraw library file.', Http::STATUS_BAD_REQUEST);
+ }
+ if ($items === []) {
+ throw new InvalidArgumentException('This library has no reusable items. Upload a library with at least one item.', Http::STATUS_BAD_REQUEST);
+ }
+ if ($this->containsImageElement($items)) {
+ throw new InvalidArgumentException('This library contains image items that cannot be imported by Whiteboard yet.', Http::STATUS_BAD_REQUEST);
+ }
+
+ $this->writeGlobalTemplate($this->getGlobalTemplateFolder(), $templateName, $items);
+
+ return [
+ 'templateName' => $templateName,
+ 'itemCount' => count($items),
+ ];
+ }
+
+ /**
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ * @throws LockedException
+ * @throws NotFoundException
+ */
+ public function deleteGlobalTemplate(string $templateName): void {
+ $normalizedName = $this->normalizeTemplateName($templateName);
+ $current = $this->listGlobalTemplates();
+ $fileName = $current['loadedFiles'][$this->toCaseKey($normalizedName)] ?? null;
+ if (!is_string($fileName)) {
+ throw new NotFoundException('Organization library preset not found');
+ }
+
+ $node = $this->getGlobalTemplateFolder()->get($fileName);
+ if (!$node instanceof File) {
+ throw new NotFoundException('Organization library preset not found');
+ }
+ $node->delete();
+ }
+
+ /**
+ * @return array>|null
+ */
+ public function parseLibraryContent(string $content): ?array {
+ try {
+ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException) {
+ return null;
+ }
+
+ if (!is_array($data)) {
+ return null;
+ }
+
+ if (array_key_exists('libraryItems', $data)) {
+ return is_array($data['libraryItems']) ? $this->normalizeLibraryItems($data['libraryItems']) : null;
+ }
+
+ if (array_key_exists('library', $data)) {
+ return is_array($data['library']) ? $this->normalizeLegacyLibraryItems($data['library']) : null;
+ }
+
+ if ($this->isListArray($data)) {
+ return $this->normalizeLibraryItems($data);
+ }
+
+ return null;
+ }
+
+ /**
+ * @return array{templates: array, loadedFiles: array, files: array}
+ *
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ * @throws LockedException
+ */
+ private function listGlobalTemplates(): array {
+ $templateFolder = $this->getGlobalTemplateFolder();
+ $templates = [];
+ $loadedFiles = [];
+ $files = [];
+
+ foreach ($templateFolder->getDirectoryListing() as $node) {
+ if (!$node instanceof File || !$this->isLibraryFileName($node->getName())) {
+ continue;
+ }
+
+ $templateName = $this->getGlobalTemplateNameFromFileName($node->getName());
+ $caseKey = $this->toCaseKey($templateName);
+ $loadedFiles[$caseKey] = $node->getName();
+ $items = $this->parseLibraryContent($node->getContent());
+ if ($items === null) {
+ $this->logger->warning('Skipping malformed organization whiteboard library preset', [
+ 'file' => $node->getName(),
+ ]);
+ continue;
+ }
+
+ $templates[] = [
+ 'templateName' => $templateName,
+ 'items' => $items,
+ ];
+ $files[$caseKey] = $node;
+ }
+
+ usort($templates, static fn (array $left, array $right): int => strcasecmp($left['templateName'], $right['templateName']));
+
+ return [
+ 'templates' => $templates,
+ 'loadedFiles' => $loadedFiles,
+ 'files' => $files,
+ ];
+ }
+
+ /**
+ * @throws NotPermittedException
+ */
+ private function getGlobalTemplateFolder(): Folder {
+ $instanceId = $this->config->getSystemValueString('instanceid', '');
+ if ($instanceId === '') {
+ throw new RuntimeException('No instance id configured');
+ }
+
+ $appDataRoot = $this->ensureChildFolder($this->rootFolder, 'appdata_' . $instanceId);
+ $appFolder = $this->ensureChildFolder($appDataRoot, 'whiteboard');
+ return $this->ensureChildFolder($appFolder, self::GLOBAL_TEMPLATE_DIR);
+ }
+
+ /**
+ * @throws NotPermittedException
+ * @throws NotFoundException
+ */
+ private function getUserTemplateFolder(string $uid): Folder {
+ if (!$this->templateManager->hasTemplateDirectory()) {
+ $this->templateManager->initializeTemplateDirectory(null, $uid, false);
+ }
+
+ $userFolder = $this->rootFolder->getUserFolder($uid);
+ $templatesPath = $this->templateManager->getTemplatePath();
+ $templatesFolder = $userFolder->get($templatesPath);
+
+ if (!$templatesFolder instanceof Folder) {
+ throw new NotFoundException('Templates folder not found for user: ' . $uid);
+ }
+
+ return $templatesFolder;
+ }
+
+ /**
+ * @return array
+ *
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ * @throws LockedException
+ */
+ private function listUserLibraryFiles(Folder $templateFolder): array {
+ $loadedFiles = [];
+
+ foreach ($templateFolder->getDirectoryListing() as $node) {
+ if (!$node instanceof File || !$this->isLibraryFileName($node->getName())) {
+ continue;
+ }
+
+ $templateName = $this->stripLibraryExtension($node->getName());
+ if (str_starts_with($templateName, self::GLOBAL_TEMPLATE_DISPLAY_PREFIX)) {
+ continue;
+ }
+ $loadedFiles[$this->toCaseKey($templateName)] = $node->getName();
+ }
+
+ return $loadedFiles;
+ }
+
+ /**
+ * @throws NotPermittedException
+ */
+ private function ensureChildFolder(Folder $folder, string $name): Folder {
+ if (!$folder->nodeExists($name)) {
+ $folder->newFolder($name);
+ }
+
+ $node = $folder->get($name);
+ if (!$node instanceof Folder) {
+ throw new RuntimeException('Expected folder at ' . $name);
+ }
+ return $node;
+ }
+
+ /**
+ * @throws JsonException
+ */
+ private function writeGlobalTemplate(Folder $templateFolder, string $templateName, array $items): void {
+ $fileName = $this->toGlobalLibraryFileName($templateName);
+ $this->writeTemplateFile($templateFolder, $fileName, $items);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ private function writeUserTemplate(Folder $templateFolder, string $templateName, array $items): void {
+ $fileName = $this->toLibraryFileName($templateName);
+ $this->writeTemplateFile($templateFolder, $fileName, $items);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ private function writeTemplateFile(Folder $templateFolder, string $fileName, array $items): void {
+ $encoded = json_encode([
+ 'type' => 'excalidrawlib',
+ 'version' => 2,
+ 'libraryItems' => $this->normalizeLibraryItems($items),
+ ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
+ $file = $templateFolder->nodeExists($fileName)
+ ? $templateFolder->get($fileName)
+ : $templateFolder->newFile($fileName);
+
+ if (!$file instanceof File) {
+ throw new GenericFileException('Failed to create or get file: ' . $fileName);
+ }
+
+ $file->putContent($encoded);
+ }
+
+ private function normalizeLegacyLibraryItems(array $libraries): array {
+ $items = [];
+ foreach ($libraries as $elements) {
+ if (!is_array($elements) || count($elements) === 0) {
+ continue;
+ }
+ $items[] = [
+ 'id' => $this->createLibraryItemId($elements),
+ 'created' => $this->nowMs(),
+ 'status' => 'published',
+ 'elements' => array_values($elements),
+ ];
+ }
+ return $this->normalizeLibraryItems($items);
+ }
+
+ private function normalizeLibraryItems(array $items): array {
+ $normalized = [];
+ foreach ($items as $item) {
+ if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements']) || count($item['elements']) === 0) {
+ continue;
+ }
+
+ unset($item['templateName'], $item['scope'], $item['filename'], $item['basename']);
+ $item['elements'] = array_values($item['elements']);
+ $item['id'] = isset($item['id']) && is_string($item['id']) && $item['id'] !== ''
+ ? $item['id']
+ : $this->createLibraryItemId($item['elements']);
+ $item['created'] = isset($item['created']) && is_numeric($item['created'])
+ ? (int)$item['created']
+ : $this->nowMs();
+ $item['status'] = isset($item['status']) && is_string($item['status'])
+ ? $item['status']
+ : 'unpublished';
+ $normalized[] = $item;
+ }
+ return $normalized;
+ }
+
+ private function normalizeTemplateName(string $templateName): string {
+ $name = trim($templateName);
+ if ($this->isLibraryFileName($name)) {
+ $name = trim($this->stripLibraryExtension($name));
+ }
+ if (str_starts_with($name, self::GLOBAL_TEMPLATE_DISPLAY_PREFIX)) {
+ $name = trim(substr($name, strlen(self::GLOBAL_TEMPLATE_DISPLAY_PREFIX)));
+ }
+
+ if ($name === '' || $name === '.' || $name === '..') {
+ throw new InvalidArgumentException('Invalid library preset name', Http::STATUS_BAD_REQUEST);
+ }
+ if (str_contains($name, '/') || str_contains($name, '\\') || preg_match('/[\x00-\x1F\x7F]/', $name) === 1) {
+ throw new InvalidArgumentException('Invalid library preset name', Http::STATUS_BAD_REQUEST);
+ }
+ if (strlen($this->toGlobalLibraryFileName($name)) > self::MAX_FILENAME_BYTES) {
+ throw new InvalidArgumentException('Library preset name is too long', Http::STATUS_BAD_REQUEST);
+ }
+
+ return $name;
+ }
+
+ private function toLibraryFileName(string $templateName): string {
+ return $templateName . self::LIB_EXTENSION;
+ }
+
+ private function toGlobalLibraryFileName(string $templateName): string {
+ return self::GLOBAL_TEMPLATE_DISPLAY_PREFIX . $templateName . self::LIB_EXTENSION;
+ }
+
+ private function isLibraryFileName(string $fileName): bool {
+ return str_ends_with(strtolower($fileName), self::LIB_EXTENSION);
+ }
+
+ private function stripLibraryExtension(string $fileName): string {
+ return substr($fileName, 0, -strlen(self::LIB_EXTENSION));
+ }
+
+ private function toCaseKey(string $value): string {
+ return strtolower($value);
+ }
+
+ private function normalizeGlobalTemplateId(string $templateId): string {
+ if (!str_starts_with($templateId, self::GLOBAL_TEMPLATE_ID_PREFIX)) {
+ throw new NotFoundException('Organization library preset not found');
+ }
+ $caseKey = substr($templateId, strlen(self::GLOBAL_TEMPLATE_ID_PREFIX));
+ if ($caseKey === '' || str_contains($caseKey, '/') || str_contains($caseKey, '\\')) {
+ throw new NotFoundException('Organization library preset not found');
+ }
+ $files = $this->listGlobalTemplates()['loadedFiles'];
+ $fileName = $files[$caseKey] ?? null;
+ if (!is_string($fileName)) {
+ throw new NotFoundException('Organization library preset not found');
+ }
+ return $this->getGlobalTemplateNameFromFileName($fileName);
+ }
+
+ private function containsImageElement(array $items): bool {
+ foreach ($items as $item) {
+ if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements'])) {
+ continue;
+ }
+ foreach ($item['elements'] as $element) {
+ if (is_array($element) && ($element['type'] ?? null) === 'image') {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private function createLibraryItemId(array $elements): string {
+ $encoded = json_encode($elements);
+ return substr(hash('sha256', $encoded !== false ? $encoded : serialize($elements)), 0, 20);
+ }
+
+ private function nowMs(): int {
+ return (int)floor((float)microtime(true) * 1000.0);
+ }
+
+ private function isListArray(array $value): bool {
+ return $value === [] || array_keys($value) === range(0, count($value) - 1);
+ }
}
diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php
index c386b7d4..79142624 100644
--- a/lib/Settings/Admin.php
+++ b/lib/Settings/Admin.php
@@ -13,6 +13,7 @@
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Settings\ISettings;
+use OCP\Util;
class Admin implements ISettings {
public function __construct(
@@ -28,6 +29,8 @@ public function getForm(): TemplateResponse {
$this->initialState->provideInitialState('secret', $this->configService->getWhiteboardSharedSecret());
$this->initialState->provideInitialState('jwt', $this->jwtService->generateJWTFromPayload([]));
$this->initialState->provideInitialState('maxFileSize', $this->configService->getMaxFileSize());
+ [$major] = Util::getVersion();
+ $this->initialState->provideInitialState('globalLibraryTemplatesSupported', $major >= 30);
$response = new TemplateResponse(
'whiteboard',
'admin',
diff --git a/lib/Template/GlobalLibraryTemplateProvider.php b/lib/Template/GlobalLibraryTemplateProvider.php
new file mode 100644
index 00000000..b6a5801b
--- /dev/null
+++ b/lib/Template/GlobalLibraryTemplateProvider.php
@@ -0,0 +1,78 @@
+ new Template(
+ self::class,
+ $this->libraryService->getGlobalTemplateId($this->libraryService->getGlobalTemplateNameFromFileName($file->getName())),
+ $file
+ ),
+ array_values($this->libraryService->getGlobalTemplateFiles())
+ );
+ } catch (Exception $e) {
+ $this->logger->warning('Failed to list organization whiteboard library presets', [
+ 'exception' => $e,
+ ]);
+ return [];
+ }
+ }
+
+ /**
+ * @throws NotFoundException
+ */
+ #[\Override]
+ public function getCustomTemplate(string $template): File {
+ try {
+ return $this->libraryService->getGlobalTemplateFile($template);
+ } catch (NotFoundException $e) {
+ throw $e;
+ } catch (Exception $e) {
+ $this->logger->warning('Failed to load organization whiteboard library preset', [
+ 'template' => $template,
+ 'exception' => $e,
+ ]);
+ throw new NotFoundException('Organization library preset not found');
+ }
+ }
+}
diff --git a/src/App.tsx b/src/App.tsx
index daa742c9..d802ee1f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,13 +5,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
-import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
+import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import type { FormEvent } from 'react'
import { getCurrentUser } from '@nextcloud/auth'
-import { translate as t } from '@nextcloud/l10n'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'
import { Excalidraw as ExcalidrawComponent, useHandleLibrary, Sidebar, isElementLink } from '@nextcloud/excalidraw'
import '@excalidraw/excalidraw/index.css'
-import type { LibraryItems } from '@nextcloud/excalidraw/dist/types/excalidraw/types'
+import type { ExcalidrawImperativeAPI, LibraryItems } from '@nextcloud/excalidraw/dist/types/excalidraw/types'
import { useExcalidrawStore } from './stores/useExcalidrawStore'
import { useWhiteboardConfigStore } from './stores/useWhiteboardConfigStore'
import { useThemeHandling } from './hooks/useThemeHandling'
@@ -54,6 +55,7 @@ import { VotingSidebar } from './components/VotingSidebar'
import { useVoting } from './hooks/useVoting'
import { useContextMenuFilter } from './hooks/useContextMenuFilter'
import { useDisableExternalLibraries } from './hooks/useDisableExternalLibraries'
+import { showError, showSuccess } from '@nextcloud/dialogs'
const Excalidraw = memo(ExcalidrawComponent)
@@ -61,6 +63,12 @@ const MemoizedNetworkStatusIndicator = memo(NetworkStatusIndicator)
const MemoizedAuthErrorNotification = memo(AuthErrorNotification)
const MemoizedExcalidrawMenu = memo(ExcalidrawMenu)
+type LibraryTemplateDialogSource = 'library' | 'selection'
+
+function formatLibraryItemCount(count: number): string {
+ return n('whiteboard', '%n library item', '%n library items', count)
+}
+
export interface WhiteboardAppProps {
fileId: number
fileName: string
@@ -130,7 +138,14 @@ export default function App({
const { renderAssistant } = useAssistant()
const { renderEmojiPicker } = useEmojiPicker()
const { onChange: onChangeSync, onPointerUpdate } = useSync()
- const { fetchLibraryItems, updateLibraryItems, isLibraryLoaded, setIsLibraryLoaded } = useLibrary()
+ const { fetchLibraryItems, updateLibraryItems, saveLibraryTemplate, isLibraryLoaded, setIsLibraryLoaded } = useLibrary()
+ const initialLibraryItemIdsRef = useRef>(new Set())
+ const [libraryTemplateDialogItems, setLibraryTemplateDialogItems] = useState(null)
+ const [libraryTemplateDialogSource, setLibraryTemplateDialogSource] = useState('library')
+ const [libraryTemplateName, setLibraryTemplateName] = useState('')
+ const [libraryTemplateError, setLibraryTemplateError] = useState(null)
+ const [isSavingLibraryTemplate, setIsSavingLibraryTemplate] = useState(false)
+ const libraryTemplateNameInputRef = useRef(null)
useCollaboration()
const { isReadOnly, refreshReadOnlyState } = useReadOnlyState()
@@ -165,6 +180,12 @@ export default function App({
useContextMenuFilter(excalidrawAPI)
useDisableExternalLibraries()
+ useEffect(() => {
+ if (libraryTemplateDialogItems) {
+ libraryTemplateNameInputRef.current?.focus()
+ }
+ }, [libraryTemplateDialogItems])
+
useEffect(() => {
const handleVideoError = (e: Event) => {
const target = e.target as HTMLElement
@@ -285,10 +306,18 @@ export default function App({
}, [handleExternalRestore, normalizedFileId])
// Use the board data manager hook
- const { saveOnUnmount, isLoading } = useBoardDataManager()
+ const {
+ saveOnUnmount,
+ isLoading,
+ getInitialLibraryItems,
+ getInitialLibraryItemsPresent,
+ } = useBoardDataManager()
// Effect to handle fileId changes - cleanup previous board data
useEffect(() => {
+ setIsLibraryLoaded(false)
+ initialLibraryItemIdsRef.current = new Set()
+
// Clear any existing Excalidraw data when fileId changes
if (excalidrawAPI) {
excalidrawAPI.resetScene()
@@ -303,13 +332,16 @@ export default function App({
saveOnUnmount()
}
}
- }, [normalizedFileId, excalidrawAPI, resetInitialDataPromise, saveOnUnmount])
+ }, [normalizedFileId, excalidrawAPI, resetInitialDataPromise, saveOnUnmount, setIsLibraryLoaded])
useEffect(() => {
- resetInitialDataPromise()
+ if (isLoading) {
+ return
+ }
// Fetch library items from the API
window.name = fileName
+ setIsLibraryLoaded(false)
const fetchLibInterval = setInterval(async () => {
const api = useExcalidrawStore.getState().excalidrawAPI
if (!api) {
@@ -319,8 +351,26 @@ export default function App({
clearInterval(fetchLibInterval)
try {
const libraryItems = await fetchLibraryItems()
+ const embeddedLibraryItems = getInitialLibraryItems()
+ const embeddedLibraryItemIds = new Set(
+ getInitialLibraryItemsPresent()
+ ? embeddedLibraryItems.map(item => item?.id).filter((id): id is string => typeof id === 'string' && id !== '')
+ : [],
+ )
+ const mergedLibraryItems = [...(libraryItems || [])]
+ const seenItemIds = new Set(mergedLibraryItems.map(item => item.id).filter(Boolean))
+ for (const item of embeddedLibraryItems) {
+ if (item.id && seenItemIds.has(item.id)) {
+ continue
+ }
+ mergedLibraryItems.push(item)
+ if (item.id) {
+ seenItemIds.add(item.id)
+ }
+ }
+ initialLibraryItemIdsRef.current = embeddedLibraryItemIds
await api.updateLibrary({
- libraryItems: libraryItems || [],
+ libraryItems: mergedLibraryItems,
})
setIsLibraryLoaded(true)
} catch (error) {
@@ -328,7 +378,18 @@ export default function App({
}
}, 1000)
- // On unmount: Clean up all stores to prevent stale state
+ return () => clearInterval(fetchLibInterval)
+ }, [
+ fileName,
+ fetchLibraryItems,
+ getInitialLibraryItems,
+ getInitialLibraryItemsPresent,
+ isLoading,
+ normalizedFileId,
+ setIsLibraryLoaded,
+ ])
+
+ useEffect(() => {
return () => {
// Save any pending changes before resetting stores
saveOnUnmount()
@@ -336,11 +397,12 @@ export default function App({
// Reset all stores
resetStore()
resetExcalidrawAPI()
+ initialLibraryItemIdsRef.current = new Set()
// Terminate the worker
terminateWorker()
}
- }, [resetInitialDataPromise, resetStore, resetExcalidrawAPI, terminateWorker, saveOnUnmount])
+ }, [resetStore, resetExcalidrawAPI, terminateWorker, saveOnUnmount])
const [activeCommentThreadId, setActiveCommentThreadId] = useState(null)
const [commentSidebarDocked, setCommentSidebarDocked] = useState(false)
@@ -397,11 +459,11 @@ export default function App({
return
}
try {
- await updateLibraryItems(items)
+ await updateLibraryItems(items, initialLibraryItemIdsRef.current)
} catch (error) {
logger.error('[App] Error syncing library items:', error)
}
- }, [isLibraryLoaded])
+ }, [isLibraryLoaded, updateLibraryItems])
const libraryReturnUrl = encodeURIComponent(window.location.href)
@@ -439,6 +501,63 @@ export default function App({
return Array.from({ length: 40 }, () => Math.floor(Math.random() * 16).toString(16)).join('')
}, [maxImageSizeBytes, maxImageSizeMb])
+ const closeLibraryTemplateDialog = useCallback(() => {
+ if (isSavingLibraryTemplate) {
+ return
+ }
+ setLibraryTemplateDialogItems(null)
+ setLibraryTemplateDialogSource('library')
+ setLibraryTemplateName('')
+ setLibraryTemplateError(null)
+ }, [isSavingLibraryTemplate])
+
+ const onLibrarySaveAsTemplate = useCallback((items: LibraryItems, context?: { source?: LibraryTemplateDialogSource }) => {
+ if (isReadOnly || isVersionPreview) {
+ return
+ }
+ if (items.length === 0) {
+ showError(t('whiteboard', 'Add at least one library item before saving a preset.'))
+ return
+ }
+ setLibraryTemplateDialogItems(items)
+ setLibraryTemplateDialogSource(context?.source === 'selection' ? 'selection' : 'library')
+ setLibraryTemplateName('')
+ setLibraryTemplateError(null)
+ }, [isReadOnly, isVersionPreview])
+
+ const submitLibraryTemplateDialog = useCallback(async (event: FormEvent) => {
+ event.preventDefault()
+ if (!libraryTemplateDialogItems || isSavingLibraryTemplate) {
+ return
+ }
+
+ const templateName = libraryTemplateName.trim()
+ if (templateName === '') {
+ setLibraryTemplateError(t('whiteboard', 'Enter a library preset name.'))
+ return
+ }
+
+ setIsSavingLibraryTemplate(true)
+ setLibraryTemplateError(null)
+ try {
+ await saveLibraryTemplate(templateName, libraryTemplateDialogItems)
+ showSuccess(t('whiteboard', 'Library preset saved.'))
+ setLibraryTemplateDialogItems(null)
+ setLibraryTemplateDialogSource('library')
+ setLibraryTemplateName('')
+ } catch (error) {
+ const status = (error as { status?: number }).status
+ const message = status === 409
+ ? t('whiteboard', 'A library preset with this name already exists.')
+ : t('whiteboard', 'Could not save library preset.')
+ setLibraryTemplateError(message)
+ showError(message)
+ logger.error('[App] Error saving library preset:', error)
+ } finally {
+ setIsSavingLibraryTemplate(false)
+ }
+ }, [isSavingLibraryTemplate, libraryTemplateDialogItems, libraryTemplateName, saveLibraryTemplate])
+
const handleOnChange = useCallback(() => {
if (isVersionPreview) {
return
@@ -469,6 +588,15 @@ export default function App({
isVersionPreview ? 'App App--version-preview' : 'App'
), [isVersionPreview])
+ const onExcalidrawAPI = useCallback((api: ExcalidrawImperativeAPI | null) => {
+ if (api) {
+ setExcalidrawAPI(api)
+ return
+ }
+
+ resetExcalidrawAPI()
+ }, [resetExcalidrawAPI, setExcalidrawAPI])
+
if (isLoading) {
return (
@@ -524,7 +652,7 @@ export default function App({
validateEmbeddable={() => true}
renderEmbeddable={Embeddable}
beforeElementCreated={beforeElementCreated}
- excalidrawAPI={setExcalidrawAPI}
+ onExcalidrawAPI={onExcalidrawAPI}
initialData={initialDataPromise}
generateIdForFile={generateIdForFile}
onPointerUpdate={onPointerUpdate}
@@ -539,6 +667,7 @@ export default function App({
}}
onLinkOpen={onLinkOpen}
onLibraryChange={onLibraryChange}
+ onLibrarySaveAsTemplate={isReadOnly || isVersionPreview ? undefined : onLibrarySaveAsTemplate}
langCode={lang}
libraryReturnUrl={libraryReturnUrl}
>
@@ -616,6 +745,71 @@ export default function App({
settings={creatorDisplaySettings}
/>
)}
+ {libraryTemplateDialogItems && (
+
+ )}
)
diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue
index 54674475..b902de0a 100644
--- a/src/components/AdminSettings.vue
+++ b/src/components/AdminSettings.vue
@@ -49,6 +49,46 @@
+
+
+ {{ t('whiteboard', 'Upload .excalidrawlib files that users can choose when creating a new whiteboard. Presets are copied into the new board and are not linked afterwards.') }}
+
+
+
+ {{ uploadingGlobalLibraryTemplate ? t('whiteboard', 'Uploading…') : t('whiteboard', 'Upload library preset') }}
+
+
+
+
+
+
+ {{ t('whiteboard', 'Loading organization library presets…') }}
+
+
+ {{ t('whiteboard', 'No organization library presets yet. Upload an .excalidrawlib file to let users start new whiteboards with reusable library items.') }}
+
+
+
diff --git a/src/hooks/useBoardDataManager.ts b/src/hooks/useBoardDataManager.ts
index 699c11b6..554f61da 100644
--- a/src/hooks/useBoardDataManager.ts
+++ b/src/hooks/useBoardDataManager.ts
@@ -18,10 +18,25 @@ import logger from '../utils/logger'
import { computeElementVersionHash, mergeSceneElements } from '../utils/syncSceneData'
import { sanitizeAppStateForSync } from '../utils/sanitizeAppState'
+function sanitizeLibraryItems(items: unknown): any[] {
+ if (!Array.isArray(items)) {
+ return []
+ }
+
+ return items.filter((item) => (
+ item
+ && typeof item === 'object'
+ && Array.isArray((item as any).elements)
+ && (item as any).elements.length > 0
+ ))
+}
+
export function useBoardDataManager() {
const [isLoading, setIsLoading] = useState(true)
const loadingTimeoutsRef = useRef>(new Set())
const currentFileIdRef = useRef(null)
+ const initialLibraryItemsRef = useRef([])
+ const initialLibraryItemsPresentRef = useRef(false)
const {
fileId,
@@ -82,6 +97,9 @@ export function useBoardDataManager() {
}, [])
const loadBoard = useCallback(async () => {
+ initialLibraryItemsRef.current = []
+ initialLibraryItemsPresentRef.current = false
+
if (isVersionPreview) {
try {
if (!versionSource) {
@@ -210,6 +228,7 @@ export function useBoardDataManager() {
dataToUse = {
elements: reconciledElements,
files: mergedFiles,
+ ...(Array.isArray(serverData.libraryItems) ? { libraryItems: sanitizeLibraryItems(serverData.libraryItems) } : {}),
appState: mergedAppState,
scrollToContent: serverScrollToContent,
}
@@ -266,6 +285,9 @@ export function useBoardDataManager() {
const sanitizedAppState = sanitizeAppStateForSync(dataToUse.appState)
const finalAppState = { ...defaultSettings, ...sanitizedAppState }
const files = dataToUse.files || {}
+ const libraryItems = sanitizeLibraryItems(dataToUse.libraryItems)
+ initialLibraryItemsRef.current = libraryItems
+ initialLibraryItemsPresentRef.current = Array.isArray(dataToUse.libraryItems)
// Force a small delay to ensure the component is ready to receive the data
const timeout = setTimeout(() => {
@@ -396,9 +418,14 @@ export function useBoardDataManager() {
}
}, [cancelPendingTimeouts])
+ const getInitialLibraryItems = useCallback(() => initialLibraryItemsRef.current, [])
+ const getInitialLibraryItemsPresent = useCallback(() => initialLibraryItemsPresentRef.current, [])
+
return {
isLoading,
loadBoard,
saveOnUnmount,
+ getInitialLibraryItems,
+ getInitialLibraryItemsPresent,
}
}
diff --git a/src/hooks/useLibrary.ts b/src/hooks/useLibrary.ts
index 7721c7ab..2f3850b6 100644
--- a/src/hooks/useLibrary.ts
+++ b/src/hooks/useLibrary.ts
@@ -14,6 +14,19 @@ type LibraryItemExtended = LibraryItem & {
filename?: string;
}
+type LibrarySaveError = Error & {
+ status?: number
+}
+
+function cleanLibraryItem(item: LibraryItem): LibraryItem {
+ const cleanItem = { ...item } as LibraryItem & Record
+ delete cleanItem.templateName
+ delete cleanItem.scope
+ delete cleanItem.filename
+ delete cleanItem.basename
+ return cleanItem
+}
+
export function useLibrary() {
const { getJWT } = useJWTStore(
useShallow(state => ({
@@ -90,15 +103,18 @@ export function useLibrary() {
logger.error('[Library] Error fetching library:', error)
return null
}
- })
+ }, [getJWT])
- const updateLibraryItems = useCallback(async (items: LibraryItems): Promise => {
+ const updateLibraryItems = useCallback(async (items: LibraryItems, excludedItemIds: Set = new Set()): Promise => {
try {
const jwt = await getJWT()
if (!jwt) {
logger.warn('[Library] No JWT found, cannot update library')
return
}
+ const itemsToSave = excludedItemIds.size === 0
+ ? items
+ : items.filter(item => !item.id || !excludedItemIds.has(item.id))
const url = generateUrl('apps/whiteboard/library')
const response = await globalThis.fetch(url, {
method: 'PUT',
@@ -107,7 +123,7 @@ export function useLibrary() {
'X-Requested-With': 'XMLHttpRequest',
Authorization: `Bearer ${jwt}`,
},
- body: JSON.stringify({ items }),
+ body: JSON.stringify({ items: itemsToSave }),
})
if (!response.ok) {
@@ -116,11 +132,40 @@ export function useLibrary() {
} catch (error) {
logger.error('[Library] Error updating library:', error)
}
- })
+ }, [getJWT])
+
+ const saveLibraryTemplate = useCallback(async (templateName: string, items: LibraryItems): Promise => {
+ const jwt = await getJWT()
+ if (!jwt) {
+ logger.warn('[Library] No JWT found, cannot save library preset')
+ return
+ }
+
+ const url = generateUrl('apps/whiteboard/library/template')
+ const response = await globalThis.fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ Authorization: `Bearer ${jwt}`,
+ },
+ body: JSON.stringify({
+ templateName,
+ items: items.map(cleanLibraryItem),
+ }),
+ })
+
+ if (!response.ok) {
+ const error = new Error(`Failed to save library preset: ${response.statusText}`) as LibrarySaveError
+ error.status = response.status
+ throw error
+ }
+ }, [getJWT])
return {
fetchLibraryItems,
updateLibraryItems,
+ saveLibraryTemplate,
isLibraryLoaded,
setIsLibraryLoaded,
}
diff --git a/src/styles/globals/_layout.scss b/src/styles/globals/_layout.scss
index 7e305384..5e532d54 100644
--- a/src/styles/globals/_layout.scss
+++ b/src/styles/globals/_layout.scss
@@ -237,6 +237,113 @@
}
}
+.library-template-dialog__backdrop {
+ position: absolute;
+ inset: 0;
+ z-index: 100021;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ background: rgba(0, 0, 0, 0.35);
+}
+
+.library-template-dialog {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ width: min(420px, 100%);
+ padding: 20px;
+ color: var(--color-main-text);
+ background: var(--color-main-background);
+ border-radius: var(--border-radius-large);
+ box-shadow: 0 4px 18px var(--color-box-shadow);
+
+ h2 {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 700;
+ line-height: 1.3;
+ }
+
+ p {
+ margin: 0;
+ }
+
+ label {
+ font-weight: 600;
+ }
+
+ input {
+ width: 100%;
+ min-height: 38px;
+ padding: 8px 10px;
+ color: var(--color-main-text);
+ background: var(--color-main-background);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius);
+
+ &:focus-visible {
+ @include tokens.focus-ring();
+ }
+ }
+
+ .library-template-dialog__input--error {
+ border-color: var(--color-border-error, var(--color-text-error, var(--color-error, #d93025)));
+ }
+}
+
+.library-template-dialog__hint,
+.library-template-dialog__count {
+ color: var(--color-text-maxcontrast);
+ line-height: 1.4;
+}
+
+.library-template-dialog__error {
+ color: var(--color-text-error, var(--color-error, #d93025));
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.library-template-dialog__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.library-template-dialog__button {
+ min-height: 34px;
+ padding: 6px 14px;
+ color: var(--color-main-text);
+ background: var(--color-background-hover);
+ border: none;
+ border-radius: var(--border-radius);
+ cursor: pointer;
+
+ &:hover {
+ background: var(--color-background-dark);
+ }
+
+ &:focus-visible {
+ @include tokens.focus-ring();
+ }
+
+ &:disabled {
+ cursor: default;
+ opacity: 0.6;
+ }
+}
+
+.library-template-dialog__button--primary {
+ color: var(--color-primary-text);
+ background: var(--color-primary);
+
+ &:hover {
+ background: var(--color-primary-element-hover);
+ }
+}
+
.version-preview-banner {
position: absolute;
top: calc(var(--default-grid-baseline) * 2);
diff --git a/tests/Unit/AppInfo/ApplicationTest.php b/tests/Unit/AppInfo/ApplicationTest.php
index be02ba36..23568342 100644
--- a/tests/Unit/AppInfo/ApplicationTest.php
+++ b/tests/Unit/AppInfo/ApplicationTest.php
@@ -10,10 +10,19 @@
namespace OCA\Whiteboard\AppInfo;
+use OCA\Whiteboard\Template\GlobalLibraryTemplateProvider;
+use OCP\AppFramework\Bootstrap\IRegistrationContext;
+use OCP\Util;
+
class ApplicationTest extends \Test\TestCase {
public function testApp(): void {
- $registrationContext = $this->createMock(\OCP\AppFramework\Bootstrap\IRegistrationContext::class);
+ $registrationContext = $this->createMock(IRegistrationContext::class);
+ [$major] = Util::getVersion();
+ $registrationContext->expects($major >= 30 ? $this->once() : $this->never())
+ ->method('registerTemplateProvider')
+ ->with(GlobalLibraryTemplateProvider::class);
+
$app = new Application();
$app->register($registrationContext);
self::assertTrue(true);
diff --git a/tests/Unit/Listener/FileCreatedFromTemplateListenerTest.php b/tests/Unit/Listener/FileCreatedFromTemplateListenerTest.php
new file mode 100644
index 00000000..f6bc7140
--- /dev/null
+++ b/tests/Unit/Listener/FileCreatedFromTemplateListenerTest.php
@@ -0,0 +1,87 @@
+createMock(File::class);
+ $template->method('getName')->willReturn('Org library preset - Flowchart.excalidrawlib');
+ $template->method('getPath')->willReturn('/appdata_123/whiteboard/global-libraries/Org library preset - Flowchart.excalidrawlib');
+ $template->method('getContent')->willReturn(json_encode([
+ 'type' => 'excalidrawlib',
+ 'version' => 2,
+ 'libraryItems' => [
+ [
+ 'id' => 'item-1',
+ 'status' => 'published',
+ 'elements' => [
+ ['id' => 'element-1', 'type' => 'rectangle'],
+ ],
+ ],
+ ],
+ ], JSON_THROW_ON_ERROR));
+
+ $target = $this->createMock(File::class);
+ $target->method('getName')->willReturn('New whiteboard.whiteboard');
+ $target->method('getPath')->willReturn('/admin/files/New whiteboard.whiteboard');
+ $target->expects($this->once())
+ ->method('putContent')
+ ->with($this->callback(static function (string $content): bool {
+ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ return $data['elements'] === []
+ && $data['files'] === []
+ && $data['scrollToContent'] === true
+ && !isset($data['libraryMode'])
+ && !isset($data['librarySource'])
+ && count($data['libraryItems']) === 1
+ && $data['libraryItems'][0]['id'] === 'item-1';
+ }));
+
+ $listener = new FileCreatedFromTemplateListener(
+ $this->createLibraryService(),
+ $this->createMock(LoggerInterface::class),
+ );
+ $listener->handle(new FileCreatedFromTemplateEvent($template, $target, []));
+ }
+
+ public function testIgnoresUserLibraryTemplates(): void {
+ $template = $this->createMock(File::class);
+ $template->method('getName')->willReturn('My preset.excalidrawlib');
+ $template->method('getPath')->willReturn('/admin/files/Templates/My preset.excalidrawlib');
+
+ $target = $this->createMock(File::class);
+ $target->method('getName')->willReturn('New whiteboard.whiteboard');
+ $target->expects($this->never())->method('putContent');
+
+ $listener = new FileCreatedFromTemplateListener(
+ $this->createLibraryService(),
+ $this->createMock(LoggerInterface::class),
+ );
+ $listener->handle(new FileCreatedFromTemplateEvent($template, $target, []));
+ }
+
+ private function createLibraryService(): WhiteboardLibraryService {
+ return new WhiteboardLibraryService(
+ $this->createMock(ITemplateManager::class),
+ $this->createMock(IRootFolder::class),
+ $this->createMock(IConfig::class),
+ $this->createMock(LoggerInterface::class),
+ );
+ }
+}
diff --git a/tests/Unit/Service/WhiteboardContentServiceTest.php b/tests/Unit/Service/WhiteboardContentServiceTest.php
new file mode 100644
index 00000000..360a6b0c
--- /dev/null
+++ b/tests/Unit/Service/WhiteboardContentServiceTest.php
@@ -0,0 +1,57 @@
+createMock(File::class);
+ $file->method('getId')->willReturn(123);
+ $file->method('getContent')->willReturn(json_encode([
+ 'elements' => [
+ ['id' => 'old-element', 'type' => 'rectangle'],
+ ],
+ 'files' => [],
+ 'libraryItems' => [
+ [
+ 'id' => 'library-item-1',
+ 'elements' => [
+ ['id' => 'library-element-1', 'type' => 'ellipse'],
+ ],
+ ],
+ ],
+ 'scrollToContent' => true,
+ ], JSON_THROW_ON_ERROR));
+
+ $file->expects($this->once())
+ ->method('putContent')
+ ->with($this->callback(static function (string $content): bool {
+ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ return $data['elements'][0]['id'] === 'new-element'
+ && $data['libraryItems'][0]['id'] === 'library-item-1'
+ && !isset($data['libraryMode'])
+ && !isset($data['librarySource']);
+ }));
+
+ $service = new WhiteboardContentService($this->createMock(LoggerInterface::class));
+ $service->updateContent($file, [
+ 'data' => [
+ 'elements' => [
+ ['id' => 'new-element', 'type' => 'diamond'],
+ ],
+ 'files' => [],
+ 'scrollToContent' => true,
+ ],
+ ]);
+ }
+}