From 246784c2d6c8b39eb059ea9ef8440681cf71ac45 Mon Sep 17 00:00:00 2001
From: Hoang Pham
Date: Tue, 5 May 2026 00:12:19 +0700
Subject: [PATCH] Implement whiteboard library templates
Signed-off-by: Hoang Pham
---
appinfo/routes.php | 8 +
lib/AppInfo/Application.php | 8 +
lib/Controller/SettingsController.php | 43 ++
lib/Controller/WhiteboardController.php | 33 +-
.../FileCreatedFromTemplateListener.php | 189 +++++
lib/Service/WhiteboardContentService.php | 79 +-
lib/Service/WhiteboardLibraryService.php | 695 ++++++++++++++++--
.../GlobalLibraryTemplateProvider.php | 74 ++
package.json | 2 +-
src/App.tsx | 297 +++++++-
src/components/AdminSettings.vue | 150 +++-
src/hooks/useBoardDataManager.ts | 90 +++
src/hooks/useLibrary.ts | 311 ++++++--
src/styles/globals/_layout.scss | 102 +++
src/utils/sanitizeAppState.ts | 1 +
tests/Unit/AppInfo/ApplicationTest.php | 9 +-
.../FileCreatedFromTemplateListenerTest.php | 82 +++
vite.config.ts | 13 +
18 files changed, 2033 insertions(+), 153 deletions(-)
create mode 100644 lib/Listener/FileCreatedFromTemplateListener.php
create mode 100644 lib/Template/GlobalLibraryTemplateProvider.php
create mode 100644 tests/Unit/Listener/FileCreatedFromTemplateListenerTest.php
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 3e8e0cb3..9dae8c34 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -23,6 +23,8 @@
['name' => 'Whiteboard#getLib', 'url' => 'library', 'verb' => 'GET'],
/** @see WhiteboardController::updateLib() */
['name' => 'Whiteboard#updateLib', 'url' => 'library', 'verb' => 'PUT'],
+ /** @see WhiteboardController::saveLibTemplate() */
+ ['name' => 'Whiteboard#saveLibTemplate', 'url' => 'library/template', 'verb' => 'POST'],
/** @see WhiteboardController::update() */
['name' => 'Whiteboard#update', 'url' => '{fileId}', 'verb' => 'PUT'],
/** @see WhiteboardController::show() */
@@ -33,6 +35,12 @@
['name' => 'Recording#upload', 'url' => 'recording/{fileId}/upload', 'verb' => 'POST'],
/** @see SettingsController::update() */
['name' => 'Settings#update', 'url' => 'settings', 'verb' => 'POST'],
+ /** @see SettingsController::listGlobalLibraryTemplates() */
+ ['name' => 'Settings#listGlobalLibraryTemplates', 'url' => 'settings/global-library', 'verb' => 'GET'],
+ /** @see SettingsController::uploadGlobalLibraryTemplate() */
+ ['name' => 'Settings#uploadGlobalLibraryTemplate', 'url' => 'settings/global-library', 'verb' => 'POST'],
+ /** @see SettingsController::deleteGlobalLibraryTemplate() */
+ ['name' => 'Settings#deleteGlobalLibraryTemplate', 'url' => 'settings/global-library/{templateName}', 'verb' => 'DELETE'],
/** @see SettingsController::updatePersonal() */
['name' => 'Settings#updatePersonal', 'url' => 'settings/personal', 'verb' => 'POST'],
]
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 3ca22b1b..72823751 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -14,14 +14,17 @@
use OCA\Viewer\Event\LoadViewer;
use OCA\Whiteboard\Listener\AddContentSecurityPolicyListener;
use OCA\Whiteboard\Listener\BeforeTemplateRenderedListener;
+use OCA\Whiteboard\Listener\FileCreatedFromTemplateListener;
use OCA\Whiteboard\Listener\LoadTextEditorListener;
use OCA\Whiteboard\Listener\LoadViewerListener;
use OCA\Whiteboard\Listener\RegisterTemplateCreatorListener;
use OCA\Whiteboard\Settings\SetupCheck;
+use OCA\Whiteboard\Template\GlobalLibraryTemplateProvider;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
+use OCP\Files\Template\FileCreatedFromTemplateEvent;
use OCP\Files\Template\ITemplateManager;
use OCP\Files\Template\RegisterTemplateCreatorEvent;
use OCP\IL10N;
@@ -47,7 +50,12 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(LoadViewer::class, LoadViewerListener::class);
$context->registerEventListener(LoadViewer::class, LoadTextEditorListener::class);
$context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateCreatorListener::class);
+ $context->registerEventListener(FileCreatedFromTemplateEvent::class, FileCreatedFromTemplateListener::class);
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
+ [$major] = Util::getVersion();
+ if ($major >= 30) {
+ $context->registerTemplateProvider(GlobalLibraryTemplateProvider::class);
+ }
$context->registerSetupCheck(SetupCheck::class);
}
diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php
index 3b46cc88..94386b50 100644
--- a/lib/Controller/SettingsController.php
+++ b/lib/Controller/SettingsController.php
@@ -13,8 +13,10 @@
use OCA\Whiteboard\Service\ConfigService;
use OCA\Whiteboard\Service\ExceptionService;
use OCA\Whiteboard\Service\JWTService;
+use OCA\Whiteboard\Service\WhiteboardLibraryService;
use OCA\Whiteboard\Settings\SetupCheck;
use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
use OCP\IUserSession;
@@ -31,6 +33,7 @@ public function __construct(
private ConfigService $configService,
private SetupCheck $setupCheck,
private IUserSession $userSession,
+ private WhiteboardLibraryService $libraryService,
) {
parent::__construct('whiteboard', $request);
}
@@ -91,4 +94,44 @@ public function updatePersonal(): DataResponse {
return $this->exceptionService->handleException($e);
}
}
+
+ public function listGlobalLibraryTemplates(): DataResponse {
+ try {
+ return new DataResponse($this->libraryService->getGlobalTemplateMetadata());
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
+ public function uploadGlobalLibraryTemplate(): DataResponse {
+ try {
+ $uploadedFile = $this->request->getUploadedFile('file');
+ if (!is_array($uploadedFile) || !isset($uploadedFile['tmp_name'], $uploadedFile['name'])) {
+ throw new Exception('No library template uploaded', Http::STATUS_BAD_REQUEST);
+ }
+ if (($uploadedFile['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
+ throw new Exception('Library template upload failed', Http::STATUS_BAD_REQUEST);
+ }
+
+ $content = file_get_contents($uploadedFile['tmp_name']);
+ if ($content === false) {
+ throw new Exception('Failed to read uploaded library template', Http::STATUS_BAD_REQUEST);
+ }
+
+ return new DataResponse([
+ 'template' => $this->libraryService->saveGlobalTemplateFromUpload($uploadedFile['name'], $content),
+ ], Http::STATUS_CREATED);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
+ public function deleteGlobalLibraryTemplate(string $templateName): DataResponse {
+ try {
+ $this->libraryService->deleteGlobalTemplate($templateName);
+ return new DataResponse(['status' => 'success']);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
}
diff --git a/lib/Controller/WhiteboardController.php b/lib/Controller/WhiteboardController.php
index 9f720c09..bb7ee3c0 100644
--- a/lib/Controller/WhiteboardController.php
+++ b/lib/Controller/WhiteboardController.php
@@ -10,6 +10,7 @@
namespace OCA\Whiteboard\Controller;
use Exception;
+use InvalidArgumentException;
use OCA\Whiteboard\Exception\InvalidUserException;
use OCA\Whiteboard\Exception\UnauthorizedException;
use OCA\Whiteboard\Service\Authentication\GetUserFromIdServiceFactory;
@@ -110,8 +111,8 @@ public function update(int $fileId, array $data): DataResponse {
public function getLib(): DataResponse {
try {
$jwt = $this->getJwtFromRequest();
- $this->jwtService->getUserIdFromJWT($jwt);
- $data = $this->libraryService->getUserLib();
+ $userId = $this->jwtService->getUserIdFromJWT($jwt);
+ $data = $this->libraryService->getTemplates($userId);
return new DataResponse(['data' => $data]);
} catch (Exception $e) {
@@ -126,8 +127,11 @@ public function updateLib(): DataResponse {
try {
$jwt = $this->getJwtFromRequest();
$userId = $this->jwtService->getUserIdFromJWT($jwt);
- $items = $this->request->getParam('items', []);
- $this->libraryService->updateUserLib($userId, $items);
+ $templates = $this->request->getParam('templates');
+ if (!is_array($templates)) {
+ throw new InvalidArgumentException('Library templates payload is required', 400);
+ }
+ $this->libraryService->updateUserTemplates($userId, $templates);
return new DataResponse(['status' => 'success']);
} catch (Exception $e) {
@@ -135,6 +139,27 @@ public function updateLib(): DataResponse {
}
}
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ #[PublicPage]
+ public function saveLibTemplate(): DataResponse {
+ try {
+ $jwt = $this->getJwtFromRequest();
+ $userId = $this->jwtService->getUserIdFromJWT($jwt);
+ $templateName = $this->request->getParam('templateName', '');
+ $items = $this->request->getParam('items', []);
+ if (!is_string($templateName) || !is_array($items)) {
+ throw new InvalidArgumentException('Invalid library template payload', 400);
+ }
+
+ $template = $this->libraryService->saveUserTemplate($userId, $templateName, $items);
+
+ return new DataResponse(['status' => 'success', 'template' => $template]);
+ } catch (Exception $e) {
+ return $this->exceptionService->handleException($e);
+ }
+ }
+
private function getJwtFromRequest(): string {
$authHeader = $this->request->getHeader('Authorization');
if (sscanf($authHeader, 'Bearer %s', $jwt) !== 1) {
diff --git a/lib/Listener/FileCreatedFromTemplateListener.php b/lib/Listener/FileCreatedFromTemplateListener.php
new file mode 100644
index 00000000..29aaf5b4
--- /dev/null
+++ b/lib/Listener/FileCreatedFromTemplateListener.php
@@ -0,0 +1,189 @@
+ */
+/**
+ * @psalm-suppress MissingTemplateParam
+ */
+final class FileCreatedFromTemplateListener implements IEventListener {
+ private const LIB_EXTENSION = '.excalidrawlib';
+ private const WHITEBOARD_EXTENSION = '.whiteboard';
+ private const VOLATILE_ELEMENT_KEYS = [
+ 'id' => true,
+ 'seed' => true,
+ 'version' => true,
+ 'versionNonce' => true,
+ 'updated' => true,
+ 'index' => true,
+ 'groupIds' => true,
+ 'frameId' => true,
+ 'boundElements' => true,
+ 'containerId' => true,
+ ];
+
+ /**
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function __construct(
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ #[\Override]
+ public function handle(Event $event): void {
+ if (!($event instanceof FileCreatedFromTemplateEvent)) {
+ return;
+ }
+
+ $template = $event->getTemplate();
+ $target = $event->getTarget();
+ if (!($template instanceof File)) {
+ return;
+ }
+
+ if (!$this->isLibraryTemplate($template) || !$this->isWhiteboardTarget($target)) {
+ return;
+ }
+
+ $libraryItems = $this->parseLibraryItems($template);
+ if ($libraryItems === []) {
+ return;
+ }
+
+ try {
+ $target->putContent(json_encode([
+ 'elements' => [],
+ 'files' => [],
+ 'libraryItems' => $libraryItems,
+ 'scrollToContent' => true,
+ ], JSON_THROW_ON_ERROR));
+ } catch (\Throwable $e) {
+ $this->logger->warning('Failed to normalize whiteboard created from library template', [
+ 'app' => 'whiteboard',
+ 'template' => $template->getPath(),
+ 'target' => $target->getPath(),
+ 'exception' => $e,
+ ]);
+ }
+ }
+
+ private function isLibraryTemplate(File $file): bool {
+ return str_ends_with(strtolower($file->getName()), self::LIB_EXTENSION);
+ }
+
+ private function isWhiteboardTarget(File $file): bool {
+ return str_ends_with(strtolower($file->getName()), self::WHITEBOARD_EXTENSION);
+ }
+
+ private function parseLibraryItems(File $file): array {
+ try {
+ $data = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR);
+ } catch (\Throwable) {
+ return [];
+ }
+
+ if (!is_array($data)) {
+ return [];
+ }
+
+ if (isset($data['libraryItems']) && is_array($data['libraryItems'])) {
+ return $this->normalizeLibraryItems($data['libraryItems']);
+ }
+
+ if (isset($data['library']) && is_array($data['library'])) {
+ $items = [];
+ foreach ($data['library'] as $elements) {
+ if (!is_array($elements) || count($elements) === 0) {
+ continue;
+ }
+ $items[] = [
+ 'id' => $this->createLibraryItemId($elements),
+ 'elements' => array_values($elements),
+ 'status' => 'published',
+ ];
+ }
+ return $this->normalizeLibraryItems($items);
+ }
+
+ return [];
+ }
+
+ private function normalizeLibraryItems(array $items): array {
+ $normalized = [];
+ $seen = [];
+
+ foreach ($items as $item) {
+ if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements']) || count($item['elements']) === 0) {
+ continue;
+ }
+
+ unset($item['templateName'], $item['scope'], $item['filename'], $item['basename']);
+ $item['elements'] = array_values($item['elements']);
+ $key = $this->createLibraryItemId($item['elements']);
+ if (isset($seen[$key])) {
+ continue;
+ }
+ $seen[$key] = true;
+
+ $item['id'] = isset($item['id']) && is_string($item['id']) && $item['id'] !== ''
+ ? $item['id']
+ : $key;
+ $item['created'] = isset($item['created']) && is_numeric($item['created'])
+ ? (int)$item['created']
+ : $this->nowMs();
+ $item['status'] = isset($item['status']) && is_string($item['status'])
+ ? $item['status']
+ : 'unpublished';
+
+ $normalized[] = $item;
+ }
+
+ return $normalized;
+ }
+
+ private function createLibraryItemId(array $elements): string {
+ $canonicalElements = $this->canonicalizeLibraryValue($elements);
+ $encoded = json_encode($canonicalElements);
+ return substr(hash('sha256', $encoded !== false ? $encoded : serialize($canonicalElements)), 0, 20);
+ }
+
+ private function canonicalizeLibraryValue(mixed $value): mixed {
+ if (!is_array($value)) {
+ return $value;
+ }
+
+ if ($this->isListArray($value)) {
+ return array_map(fn ($item) => $this->canonicalizeLibraryValue($item), $value);
+ }
+
+ ksort($value);
+ $normalized = [];
+ foreach ($value as $key => $item) {
+ if (is_string($key) && isset(self::VOLATILE_ELEMENT_KEYS[$key])) {
+ continue;
+ }
+ $normalized[$key] = $this->canonicalizeLibraryValue($item);
+ }
+ return $normalized;
+ }
+
+ private function nowMs(): int {
+ return (int)floor((float)microtime(true) * 1000.0);
+ }
+
+ private function isListArray(array $value): bool {
+ return $value === [] || array_keys($value) === range(0, count($value) - 1);
+ }
+}
diff --git a/lib/Service/WhiteboardContentService.php b/lib/Service/WhiteboardContentService.php
index 5ba88d87..b89f8402 100644
--- a/lib/Service/WhiteboardContentService.php
+++ b/lib/Service/WhiteboardContentService.php
@@ -17,6 +17,19 @@
use Psr\Log\LoggerInterface;
final class WhiteboardContentService {
+ private const VOLATILE_ELEMENT_KEYS = [
+ 'id' => true,
+ 'seed' => true,
+ 'version' => true,
+ 'versionNonce' => true,
+ 'updated' => true,
+ 'index' => true,
+ 'groupIds' => true,
+ 'frameId' => true,
+ 'boundElements' => true,
+ 'containerId' => true,
+ ];
+
public function __construct(
private LoggerInterface $logger,
) {
@@ -168,6 +181,10 @@ private function normalizeIncomingData(array $incoming): array {
: [];
}
+ if (array_key_exists('libraryItems', $incoming) && is_array($incoming['libraryItems'])) {
+ $normalized['libraryItems'] = $this->sanitizeLibraryItems($incoming['libraryItems']);
+ }
+
if (array_key_exists('appState', $incoming) && is_array($incoming['appState'])) {
$normalized['appState'] = $this->sanitizeAppState($incoming['appState']);
}
@@ -199,6 +216,14 @@ private function isEffectivelyEmptyPayload(array $payload): bool {
return false;
}
+ $hasLibraryItems = array_key_exists('libraryItems', $payload)
+ && is_array($payload['libraryItems'])
+ && !empty($payload['libraryItems']);
+
+ if ($hasLibraryItems) {
+ return false;
+ }
+
if (array_key_exists('scrollToContent', $payload) && $payload['scrollToContent'] !== true) {
return false;
}
@@ -212,7 +237,7 @@ private function isEffectivelyEmptyPayload(array $payload): bool {
}
foreach ($payload as $key => $_value) {
- if (!in_array($key, ['elements', 'files', 'appState', 'scrollToContent'], true)) {
+ if (!in_array($key, ['elements', 'files', 'libraryItems', 'appState', 'scrollToContent'], true)) {
return false;
}
}
@@ -244,6 +269,10 @@ private function normalizeStoredData(array $stored): array {
$normalized['files'] = $this->sanitizeFiles($stored['files']);
}
+ if (array_key_exists('libraryItems', $stored) && is_array($stored['libraryItems'])) {
+ $normalized['libraryItems'] = $this->sanitizeLibraryItems($stored['libraryItems']);
+ }
+
if (array_key_exists('appState', $stored) && is_array($stored['appState'])) {
$normalized['appState'] = $this->sanitizeAppState($stored['appState']);
} elseif (array_key_exists('appState', $stored) && $stored['appState'] === null) {
@@ -274,6 +303,10 @@ private function mergeData(array $current, array $incoming): array {
$merged['files'] = $incoming['files'];
}
+ if (array_key_exists('libraryItems', $incoming)) {
+ $merged['libraryItems'] = $incoming['libraryItems'];
+ }
+
if (array_key_exists('appState', $incoming)) {
if ($incoming['appState'] === null) {
unset($merged['appState']);
@@ -331,6 +364,50 @@ private function sanitizeFiles(array $files): array {
return $sanitized;
}
+ private function sanitizeLibraryItems(array $items): array {
+ $sanitized = [];
+ $seen = [];
+
+ foreach ($items as $item) {
+ if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements']) || count($item['elements']) === 0) {
+ continue;
+ }
+
+ $item['elements'] = array_values($item['elements']);
+ $canonicalElements = $this->canonicalizeLibraryValue($item['elements']);
+ $encoded = json_encode($canonicalElements);
+ $key = hash('sha256', $encoded !== false ? $encoded : serialize($canonicalElements));
+ if (isset($seen[$key])) {
+ continue;
+ }
+ $seen[$key] = true;
+ unset($item['templateName'], $item['scope'], $item['filename'], $item['basename']);
+ $sanitized[] = $item;
+ }
+
+ return $sanitized;
+ }
+
+ private function canonicalizeLibraryValue(mixed $value): mixed {
+ if (!is_array($value)) {
+ return $value;
+ }
+
+ if ($this->isList($value)) {
+ return array_map(fn ($item) => $this->canonicalizeLibraryValue($item), $value);
+ }
+
+ ksort($value);
+ $normalized = [];
+ foreach ($value as $key => $item) {
+ if (is_string($key) && isset(self::VOLATILE_ELEMENT_KEYS[$key])) {
+ continue;
+ }
+ $normalized[$key] = $this->canonicalizeLibraryValue($item);
+ }
+ return $normalized;
+ }
+
/**
* @param array $appState
*
diff --git a/lib/Service/WhiteboardLibraryService.php b/lib/Service/WhiteboardLibraryService.php
index de27f938..454ba5a6 100644
--- a/lib/Service/WhiteboardLibraryService.php
+++ b/lib/Service/WhiteboardLibraryService.php
@@ -9,16 +9,22 @@
namespace OCA\Whiteboard\Service;
+use Exception;
+use InvalidArgumentException;
use JsonException;
-use OCA\Whiteboard\AppInfo\Application;
+use OCP\AppFramework\Http;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\GenericFileException;
+use OCP\Files\IFilenameValidator;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\Template\ITemplateManager;
+use OCP\IConfig;
use OCP\Lock\LockedException;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
/**
* @psalm-suppress UndefinedDocblockClass
@@ -26,50 +32,163 @@
* @psalm-suppress MissingDependency
*/
final class WhiteboardLibraryService {
+ private const LIB_EXTENSION = '.excalidrawlib';
+ private const PERSONAL_TEMPLATE = 'personal';
+ private const BOARD_TEMPLATE = 'board';
+ private const DEFAULT_TEMPLATE_DIR = 'Templates/';
+ private const GLOBAL_TEMPLATE_DIR = 'global-libraries';
+ private const GLOBAL_SCOPE = 'global';
+ private const USER_SCOPE = 'user';
+ private const MAX_FILENAME_BYTES = 250;
+ private const VOLATILE_ELEMENT_KEYS = [
+ 'id' => true,
+ 'seed' => true,
+ 'version' => true,
+ 'versionNonce' => true,
+ 'updated' => true,
+ 'index' => true,
+ 'groupIds' => true,
+ 'frameId' => true,
+ 'boundElements' => true,
+ 'containerId' => true,
+ ];
+
public function __construct(
private ITemplateManager $templateManager,
private IRootFolder $rootFolder,
+ private IFilenameValidator $filenameValidator,
+ private IConfig $config,
+ private LoggerInterface $logger,
) {
}
+ /**
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ * @throws LockedException
+ */
+ public function getTemplates(string $uid): array {
+ return [
+ 'templates' => $this->listUserTemplates($uid)['templates'],
+ ];
+ }
+
+ /**
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ * @throws LockedException
+ */
+ public function getGlobalTemplateMetadata(): array {
+ return [
+ 'templates' => array_map(static fn (array $template): array => [
+ 'templateName' => $template['templateName'],
+ 'scope' => $template['scope'],
+ 'itemCount' => count($template['items']),
+ ], $this->listGlobalTemplates()['templates']),
+ ];
+ }
+
+ /**
+ * @return array
+ *
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ * @throws LockedException
+ */
+ public function getGlobalTemplateFiles(): array {
+ return $this->listGlobalTemplates()['files'];
+ }
+
+ /**
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ * @throws LockedException
+ * @throws NotFoundException
+ */
+ public function getGlobalTemplateFile(string $templateName): File {
+ $normalizedName = $this->normalizeTemplateName($templateName);
+ $files = $this->getGlobalTemplateFiles();
+ $file = $files[$this->toCaseKey($normalizedName)] ?? null;
+ if (!$file instanceof File) {
+ throw new NotFoundException('Organization library template not found');
+ }
+ return $file;
+ }
+
/**
* @throws NotPermittedException
* @throws GenericFileException
* @throws LockedException
* @throws JsonException
*/
- public function getUserLib(): array {
- // Get the .excalidrawlib files from the /Templates directory
- $availableFileCreators = $this->templateManager->listTemplates();
- $templates = [];
- $libs = [];
+ public function saveGlobalTemplateFromUpload(string $fileName, string $content): array {
+ if (!$this->isLibraryFileName($fileName)) {
+ throw new InvalidArgumentException('Upload an .excalidrawlib file', Http::STATUS_BAD_REQUEST);
+ }
- foreach ($availableFileCreators as $fileCreator) {
- if ($fileCreator['app'] !== Application::APP_ID) {
- continue;
- }
- $templates = $fileCreator['templates'];
- break;
+ $templateName = $this->normalizeTemplateName($fileName);
+ $this->assertGlobalTemplateNameAllowed($templateName);
+ $caseKey = $this->toCaseKey($templateName);
+ $current = $this->listGlobalTemplates();
+
+ if (isset($current['loadedFiles'][$caseKey])) {
+ throw new RuntimeException('A library template with this name already exists. Rename the file and upload it again.', Http::STATUS_CONFLICT);
}
- foreach ($templates as $template) {
- $templateDetails = $template->jsonSerialize();
+ $items = $this->parseLibraryContent($content);
+ if ($items === null) {
+ throw new InvalidArgumentException('This is not a valid Excalidraw library file.', Http::STATUS_BAD_REQUEST);
+ }
+ if ($items === []) {
+ throw new InvalidArgumentException('This library has no reusable items. Upload a library with at least one item.', Http::STATUS_BAD_REQUEST);
+ }
- if (str_ends_with($templateDetails['basename'], '.excalidrawlib')) {
- $fileId = $templateDetails['fileid'];
- $file = $this->rootFolder->getFirstNodeById($fileId);
+ $this->writeLibraryTemplate($this->getGlobalTemplateFolder(), $templateName, $items);
- if (!$file instanceof File) {
- continue;
- }
+ return [
+ 'templateName' => $templateName,
+ 'scope' => self::GLOBAL_SCOPE,
+ 'itemCount' => count($items),
+ ];
+ }
- $lib = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR);
- $lib['basename'] = $templateDetails['basename'];
- $libs[] = $lib;
- }
+ /**
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ * @throws LockedException
+ * @throws NotFoundException
+ */
+ public function deleteGlobalTemplate(string $templateName): void {
+ $normalizedName = $this->normalizeTemplateName($templateName);
+ $current = $this->listGlobalTemplates();
+ $fileName = $current['loadedFiles'][$this->toCaseKey($normalizedName)] ?? null;
+ if (!is_string($fileName)) {
+ throw new NotFoundException('Organization library template not found');
+ }
+
+ $node = $this->getGlobalTemplateFolder()->get($fileName);
+ if (!$node instanceof File) {
+ throw new NotFoundException('Organization library template not found');
}
+ $node->delete();
+ }
- return $libs;
+ /**
+ * @throws NotPermittedException
+ * @throws NotFoundException
+ * @throws GenericFileException
+ * @throws LockedException
+ * @throws JsonException
+ */
+ public function updateUserTemplates(string $uid, array $templates): void {
+ $templateFolder = $this->getUserTemplateFolder($uid);
+ $current = $this->listUserTemplates($uid);
+ $loadedFiles = $current['loadedFiles'];
+ $payload = $this->normalizePayloadTemplates($templates, $loadedFiles);
+
+ foreach ($payload as $templateName => $items) {
+ $this->writeUserTemplate($templateFolder, $templateName, $items);
+ }
}
/**
@@ -79,57 +198,513 @@ public function getUserLib(): array {
* @throws LockedException
* @throws JsonException
*/
- public function updateUserLib(string $uid, array $items): void {
- // Check if the user has a Templates folder, if not create one
- if (!$this->templateManager->hasTemplateDirectory()) {
- $this->templateManager->initializeTemplateDirectory(null, $uid, false);
+ public function saveUserTemplate(string $uid, string $templateName, array $items): array {
+ $templateFolder = $this->getUserTemplateFolder($uid);
+ $normalizedName = $this->normalizeTemplateName($templateName);
+ $caseKey = $this->toCaseKey($normalizedName);
+ $current = $this->listUserTemplates($uid);
+ $userNames = $current['loadedFiles'];
+ $normalizedItems = $this->normalizeLibraryItems($items);
+
+ if (isset($userNames[$caseKey])) {
+ throw new RuntimeException('Library template already exists', Http::STATUS_CONFLICT);
+ }
+
+ if ($normalizedItems === []) {
+ throw new InvalidArgumentException('Library template must contain at least one item', Http::STATUS_BAD_REQUEST);
+ }
+
+ $contentKey = $this->createLibraryTemplateContentKey($normalizedItems);
+ foreach ($current['templates'] as $template) {
+ if ($this->createLibraryTemplateContentKey($template['items']) === $contentKey) {
+ throw new RuntimeException('Library template with same items already exists', Http::STATUS_CONFLICT);
+ }
+ }
+
+ $this->writeUserTemplate($templateFolder, $normalizedName, $normalizedItems);
+
+ return [
+ 'templateName' => $normalizedName,
+ 'scope' => self::USER_SCOPE,
+ 'items' => $normalizedItems,
+ ];
+ }
+
+ /**
+ * @return array{templates: array, loadedFiles: array}
+ *
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ * @throws LockedException
+ */
+ private function listUserTemplates(string $uid): array {
+ $templateFolder = $this->getUserTemplateFolder($uid);
+ $templates = [];
+ $loadedFiles = [];
+
+ foreach ($templateFolder->getDirectoryListing() as $node) {
+ if (!$node instanceof File || !$this->isLibraryFileName($node->getName())) {
+ continue;
+ }
+
+ $templateName = $this->stripLibraryExtension($node->getName());
+ $loadedFiles[$this->toCaseKey($templateName)] = $node->getName();
+ $items = $this->parseLibraryContent($node->getContent());
+ if ($items === null) {
+ $this->logger->warning('Skipping malformed whiteboard library template', [
+ 'uid' => $uid,
+ 'file' => $node->getName(),
+ ]);
+ continue;
+ }
+
+ $templates[] = [
+ 'templateName' => $templateName,
+ 'scope' => self::USER_SCOPE,
+ 'items' => $items,
+ ];
}
- // Update the .excalidrawlib files in the Templates directory
+ usort($templates, function (array $left, array $right): int {
+ return $this->sortTemplates($left, $right);
+ });
+
+ return [
+ 'templates' => $templates,
+ 'loadedFiles' => $loadedFiles,
+ ];
+ }
+
+ /**
+ * @return array{templates: array, loadedFiles: array, files: array}
+ *
+ * @throws NotPermittedException
+ * @throws GenericFileException
+ * @throws LockedException
+ */
+ private function listGlobalTemplates(): array {
+ $templateFolder = $this->getGlobalTemplateFolder();
+ $templates = [];
+ $loadedFiles = [];
+ $files = [];
+
+ foreach ($templateFolder->getDirectoryListing() as $node) {
+ if (!$node instanceof File || !$this->isLibraryFileName($node->getName())) {
+ continue;
+ }
+
+ $templateName = $this->stripLibraryExtension($node->getName());
+ $caseKey = $this->toCaseKey($templateName);
+ $loadedFiles[$caseKey] = $node->getName();
+ $items = $this->parseLibraryContent($node->getContent());
+ if ($items === null) {
+ $this->logger->warning('Skipping malformed organization whiteboard library template', [
+ 'file' => $node->getName(),
+ ]);
+ continue;
+ }
+
+ $templates[] = [
+ 'templateName' => $templateName,
+ 'scope' => self::GLOBAL_SCOPE,
+ 'items' => $items,
+ ];
+ $files[$caseKey] = $node;
+ }
+
+ usort($templates, function (array $left, array $right): int {
+ return $this->sortTemplates($left, $right);
+ });
+
+ return [
+ 'templates' => $templates,
+ 'loadedFiles' => $loadedFiles,
+ 'files' => $files,
+ ];
+ }
+
+ /**
+ * @throws NotPermittedException
+ */
+ private function getUserTemplateFolder(string $uid): Folder {
$userFolder = $this->rootFolder->getUserFolder($uid);
- $templatesPath = $this->templateManager->getTemplatePath();
- $templatesFolder = $userFolder->get($templatesPath);
+ $configuredPath = $this->normalizeTemplatePath(
+ $this->config->getUserValue($uid, 'core', 'templateDirectory', '')
+ );
+ $templateFolder = $configuredPath !== null
+ ? $this->getExistingFolder($userFolder, $configuredPath)
+ : null;
+
+ if (!$templateFolder instanceof Folder) {
+ $templatePath = $this->templateManager->initializeTemplateDirectory(self::DEFAULT_TEMPLATE_DIR, $uid, false);
+ $templateFolder = $this->ensureFolder($userFolder, $this->normalizeTemplatePath($templatePath) ?? self::DEFAULT_TEMPLATE_DIR);
+ }
+
+ $this->migrateRootPersonalTemplate($userFolder, $templateFolder);
+
+ return $templateFolder;
+ }
+
+ private function getExistingFolder(Folder $userFolder, string $path): ?Folder {
+ try {
+ if (!$userFolder->nodeExists($path)) {
+ return null;
+ }
+ $node = $userFolder->get($path);
+ return $node instanceof Folder ? $node : null;
+ } catch (Exception) {
+ return null;
+ }
+ }
+
+ /**
+ * @throws NotPermittedException
+ */
+ private function getGlobalTemplateFolder(): Folder {
+ $instanceId = $this->config->getSystemValueString('instanceid', '');
+ if ($instanceId === '') {
+ throw new RuntimeException('No instance id configured');
+ }
+
+ $appDataRoot = $this->ensureChildFolder($this->rootFolder, 'appdata_' . $instanceId);
+ $appFolder = $this->ensureChildFolder($appDataRoot, 'whiteboard');
+ return $this->ensureChildFolder($appFolder, self::GLOBAL_TEMPLATE_DIR);
+ }
+
+ /**
+ * @throws NotPermittedException
+ */
+ private function ensureChildFolder(Folder $folder, string $name): Folder {
+ if (!$folder->nodeExists($name)) {
+ $folder->newFolder($name);
+ }
+
+ $node = $folder->get($name);
+ if (!$node instanceof Folder) {
+ throw new RuntimeException('Expected folder at ' . $name);
+ }
+ return $node;
+ }
+
+ /**
+ * @throws NotPermittedException
+ */
+ private function ensureFolder(Folder $userFolder, string $path): Folder {
+ $normalizedPath = $this->normalizeTemplatePath($path) ?? trim(self::DEFAULT_TEMPLATE_DIR, '/');
+ if (!$userFolder->nodeExists($normalizedPath)) {
+ $userFolder->newFolder($normalizedPath);
+ }
+ $folder = $userFolder->get($normalizedPath);
+ if (!$folder instanceof Folder) {
+ throw new RuntimeException('Template path is not a folder');
+ }
+ return $folder;
+ }
+
+ private function migrateRootPersonalTemplate(Folder $userFolder, Folder $templateFolder): void {
+ $fileName = $this->toLibraryFileName(self::PERSONAL_TEMPLATE);
+ try {
+ if (!$userFolder->nodeExists($fileName)) {
+ return;
+ }
+ if ($templateFolder->nodeExists($fileName)) {
+ $this->logger->info('Leaving root personal whiteboard library in place because target exists');
+ return;
+ }
+
+ $source = $userFolder->get($fileName);
+ if (!$source instanceof File) {
+ return;
+ }
+ if ($this->parseLibraryContent($source->getContent()) === null) {
+ $this->logger->warning('Leaving malformed root personal whiteboard library in place');
+ return;
+ }
+
+ $targetPath = $templateFolder->getPath() . '/' . $fileName;
+ try {
+ $source->move($targetPath);
+ return;
+ } catch (Exception $e) {
+ $this->logger->warning('Failed to move root personal whiteboard library, trying copy/delete', [
+ 'exception' => $e->getMessage(),
+ ]);
+ }
- if (!$templatesFolder instanceof Folder) {
- throw new NotFoundException('Templates folder not found for user: ' . $uid);
+ try {
+ $source->copy($targetPath);
+ $source->delete();
+ } catch (Exception $e) {
+ $this->logger->warning('Failed to migrate root personal whiteboard library', [
+ 'exception' => $e->getMessage(),
+ ]);
+ $this->deleteTargetCopyAfterFailedMigration($templateFolder, $fileName);
+ }
+ } catch (Exception $e) {
+ $this->logger->warning('Failed to inspect root personal whiteboard library', [
+ 'exception' => $e->getMessage(),
+ ]);
}
+ }
- $files = [
- 'personal.excalidrawlib' => [
- 'type' => 'excalidrawlib',
- 'version' => 2,
- 'libraryItems' => [],
- ],
+ private function deleteTargetCopyAfterFailedMigration(Folder $templateFolder, string $fileName): void {
+ try {
+ if ($templateFolder->nodeExists($fileName)) {
+ $node = $templateFolder->get($fileName);
+ if ($node instanceof File) {
+ $node->delete();
+ }
+ }
+ } catch (Exception) {
+ }
+ }
+
+ /**
+ * @throws JsonException
+ */
+ private function writeUserTemplate(Folder $templateFolder, string $templateName, array $items): void {
+ $this->writeLibraryTemplate($templateFolder, $templateName, $items);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ private function writeLibraryTemplate(Folder $templateFolder, string $templateName, array $items): void {
+ $fileName = $this->toLibraryFileName($templateName);
+ $encoded = $this->encodeLibraryFile($items);
+ $file = $templateFolder->nodeExists($fileName)
+ ? $templateFolder->get($fileName)
+ : $templateFolder->newFile($fileName);
+
+ if (!$file instanceof File) {
+ throw new GenericFileException('Failed to create or get file: ' . $fileName);
+ }
+
+ $file->putContent($encoded);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ private function encodeLibraryFile(array $items): string {
+ $fileData = [
+ 'type' => 'excalidrawlib',
+ 'version' => 2,
+ 'libraryItems' => $this->normalizeLibraryItems($items),
];
+ $encoded = json_encode($fileData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
+ if ($this->parseLibraryContent($encoded) === null) {
+ throw new InvalidArgumentException('Generated library file is invalid', Http::STATUS_BAD_REQUEST);
+ }
+ return $encoded;
+ }
+
+ private function parseLibraryContent(string $content): ?array {
+ try {
+ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException) {
+ return null;
+ }
+
+ if (!is_array($data)) {
+ return null;
+ }
+
+ if (array_key_exists('libraryItems', $data)) {
+ return is_array($data['libraryItems']) ? $this->normalizeLibraryItems($data['libraryItems']) : null;
+ }
+
+ if (array_key_exists('library', $data)) {
+ return is_array($data['library']) ? $this->normalizeLegacyLibraryItems($data['library']) : null;
+ }
+
+ if ($this->isListArray($data)) {
+ return $this->normalizeLibraryItems($data);
+ }
+
+ return null;
+ }
+
+ private function normalizeLegacyLibraryItems(array $libraries): array {
+ $items = [];
+ foreach ($libraries as $elements) {
+ if (!is_array($elements) || count($elements) === 0) {
+ continue;
+ }
+ $items[] = [
+ 'id' => $this->createLibraryItemId($elements),
+ 'created' => $this->nowMs(),
+ 'status' => 'published',
+ 'elements' => array_values($elements),
+ ];
+ }
+ return $this->normalizeLibraryItems($items);
+ }
+ private function normalizeLibraryItems(array $items): array {
+ $normalized = [];
+ $seen = [];
foreach ($items as $item) {
- if (!isset($item['filename'])) {
- $files['personal.excalidrawlib']['libraryItems'][] = $item;
- } else {
- if (isset($files[$item['filename']])) {
- $files[$item['filename']]['libraryItems'][] = $item;
- } else {
- $files[$item['filename']] = [
- 'type' => 'excalidrawlib',
- 'version' => 2,
- 'libraryItems' => [$item],
- ];
- }
+ if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements']) || count($item['elements']) === 0) {
+ continue;
}
+ unset($item['templateName'], $item['scope'], $item['filename'], $item['basename']);
+ $item['elements'] = array_values($item['elements']);
+ $contentKey = $this->createLibraryItemId($item['elements']);
+ if (isset($seen[$contentKey])) {
+ continue;
+ }
+ $seen[$contentKey] = true;
+ $item['id'] = isset($item['id']) && is_string($item['id']) && $item['id'] !== ''
+ ? $item['id']
+ : $contentKey;
+ $item['created'] = isset($item['created']) && is_numeric($item['created'])
+ ? (int)$item['created']
+ : $this->nowMs();
+ $item['status'] = isset($item['status']) && is_string($item['status'])
+ ? $item['status']
+ : 'unpublished';
+ $normalized[] = $item;
}
+ return $normalized;
+ }
+
+ private function normalizePayloadTemplates(array $templates, array $loadedFiles): array {
+ $normalized = [];
+ $seen = [];
- foreach ($files as $filename => $fileData) {
- if ($templatesFolder->nodeExists($filename)) {
- $file = $templatesFolder->get($filename);
- } else {
- $file = $templatesFolder->newFile($filename);
+ foreach ($templates as $template) {
+ if (!is_array($template)) {
+ throw new InvalidArgumentException('Invalid library template payload', Http::STATUS_BAD_REQUEST);
+ }
+ if (isset($template['scope']) && $template['scope'] !== 'user') {
+ throw new InvalidArgumentException('Only user library templates can be updated here', Http::STATUS_BAD_REQUEST);
+ }
+ if (!isset($template['templateName']) || !is_string($template['templateName'])) {
+ throw new InvalidArgumentException('Library templateName is required', Http::STATUS_BAD_REQUEST);
}
- if (!$file instanceof File) {
- throw new GenericFileException('Failed to create or get file: ' . $filename);
+ $templateName = $this->normalizeTemplateName($template['templateName']);
+ $caseKey = $this->toCaseKey($templateName);
+ if ($caseKey !== self::PERSONAL_TEMPLATE) {
+ throw new InvalidArgumentException('Only the personal library template can be updated here', Http::STATUS_BAD_REQUEST);
+ }
+ if (isset($seen[$caseKey])) {
+ throw new RuntimeException('Duplicate library template name', Http::STATUS_CONFLICT);
}
+ $seen[$caseKey] = true;
+ $normalizedName = isset($loadedFiles[$caseKey])
+ ? $this->stripLibraryExtension($loadedFiles[$caseKey])
+ : $templateName;
+ $templateItems = $template['items'] ?? [];
+ $normalized[$normalizedName] = $this->normalizeLibraryItems(is_array($templateItems) ? $templateItems : []);
+ }
+
+ return $normalized;
+ }
+
+ private function normalizeTemplateName(string $templateName): string {
+ $name = trim($templateName);
+ if ($this->isLibraryFileName($name)) {
+ $name = trim($this->stripLibraryExtension($name));
+ }
+
+ if ($name === '' || $name === '.' || $name === '..') {
+ throw new InvalidArgumentException('Invalid library template name', Http::STATUS_BAD_REQUEST);
+ }
+ if (str_contains($name, '/') || str_contains($name, '\\') || preg_match('/[\x00-\x1F\x7F]/', $name) === 1) {
+ throw new InvalidArgumentException('Invalid library template name', Http::STATUS_BAD_REQUEST);
+ }
- $file->putContent(json_encode($fileData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
+ $fileName = $this->toLibraryFileName($name);
+ if (strlen($fileName) > self::MAX_FILENAME_BYTES) {
+ throw new InvalidArgumentException('Library template name is too long', Http::STATUS_BAD_REQUEST);
}
+
+ try {
+ $this->filenameValidator->validateFilename($fileName);
+ } catch (Exception $e) {
+ throw new InvalidArgumentException('Invalid library template name: ' . $e->getMessage(), Http::STATUS_BAD_REQUEST, $e);
+ }
+
+ return $name;
+ }
+
+ private function assertGlobalTemplateNameAllowed(string $templateName): void {
+ $caseKey = $this->toCaseKey($templateName);
+ if ($caseKey === self::PERSONAL_TEMPLATE || $caseKey === self::BOARD_TEMPLATE) {
+ throw new InvalidArgumentException('Reserved library template name', Http::STATUS_BAD_REQUEST);
+ }
+ }
+
+ private function normalizeTemplatePath(string $path): ?string {
+ $normalized = trim($path, '/');
+ return $normalized === '' ? null : $normalized;
+ }
+
+ private function toLibraryFileName(string $templateName): string {
+ return $templateName . self::LIB_EXTENSION;
+ }
+
+ private function isLibraryFileName(string $fileName): bool {
+ return str_ends_with(strtolower($fileName), self::LIB_EXTENSION);
+ }
+
+ private function stripLibraryExtension(string $fileName): string {
+ return substr($fileName, 0, -strlen(self::LIB_EXTENSION));
+ }
+
+ private function toCaseKey(string $value): string {
+ return strtolower($value);
+ }
+
+ private function createLibraryItemId(array $elements): string {
+ $canonicalElements = $this->canonicalizeLibraryValue($elements);
+ $encoded = json_encode($canonicalElements);
+ return substr(hash('sha256', $encoded !== false ? $encoded : serialize($canonicalElements)), 0, 20);
+ }
+
+ private function createLibraryTemplateContentKey(array $items): string {
+ $itemKeys = [];
+ foreach ($items as $item) {
+ if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements'])) {
+ continue;
+ }
+ $itemKeys[] = $this->createLibraryItemId($item['elements']);
+ }
+ sort($itemKeys);
+ return hash('sha256', implode("\n", $itemKeys));
+ }
+
+ private function canonicalizeLibraryValue(mixed $value): mixed {
+ if (!is_array($value)) {
+ return $value;
+ }
+
+ if ($this->isListArray($value)) {
+ return array_map(fn ($item) => $this->canonicalizeLibraryValue($item), $value);
+ }
+
+ ksort($value);
+ $normalized = [];
+ foreach ($value as $key => $item) {
+ if (is_string($key) && isset(self::VOLATILE_ELEMENT_KEYS[$key])) {
+ continue;
+ }
+ $normalized[$key] = $this->canonicalizeLibraryValue($item);
+ }
+ return $normalized;
+ }
+
+ private function nowMs(): int {
+ return (int)floor((float)microtime(true) * 1000.0);
+ }
+
+ private function isListArray(array $value): bool {
+ return $value === [] || array_keys($value) === range(0, count($value) - 1);
+ }
+
+ private function sortTemplates(array $left, array $right): int {
+ return strcasecmp($left['templateName'], $right['templateName']);
}
}
diff --git a/lib/Template/GlobalLibraryTemplateProvider.php b/lib/Template/GlobalLibraryTemplateProvider.php
new file mode 100644
index 00000000..d6d6d9eb
--- /dev/null
+++ b/lib/Template/GlobalLibraryTemplateProvider.php
@@ -0,0 +1,74 @@
+ new Template(self::class, substr($file->getName(), 0, -strlen('.excalidrawlib')), $file),
+ array_values($this->libraryService->getGlobalTemplateFiles())
+ );
+ } catch (Exception $e) {
+ $this->logger->warning('Failed to list organization whiteboard library templates', [
+ 'exception' => $e,
+ ]);
+ return [];
+ }
+ }
+
+ /**
+ * @throws NotFoundException
+ */
+ #[\Override]
+ public function getCustomTemplate(string $template): File {
+ try {
+ return $this->libraryService->getGlobalTemplateFile($template);
+ } catch (NotFoundException $e) {
+ throw $e;
+ } catch (Exception $e) {
+ $this->logger->warning('Failed to load organization whiteboard library template', [
+ 'template' => $template,
+ 'exception' => $e,
+ ]);
+ throw new NotFoundException('Organization library template not found');
+ }
+ }
+}
diff --git a/package.json b/package.json
index a096fccf..01c197b3 100644
--- a/package.json
+++ b/package.json
@@ -126,4 +126,4 @@
"node": "^24.0.0",
"npm": "^11.3.0"
}
-}
\ No newline at end of file
+}
diff --git a/src/App.tsx b/src/App.tsx
index daa742c9..b595c6d2 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,13 +5,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
-import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
+import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import type { FormEvent } from 'react'
import { getCurrentUser } from '@nextcloud/auth'
-import { translate as t } from '@nextcloud/l10n'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'
import { Excalidraw as ExcalidrawComponent, useHandleLibrary, Sidebar, isElementLink } from '@nextcloud/excalidraw'
-import '@excalidraw/excalidraw/index.css'
-import type { LibraryItems } from '@nextcloud/excalidraw/dist/types/excalidraw/types'
+import '@nextcloud/excalidraw/index.css'
+import type { ExcalidrawImperativeAPI, LibraryItems } from '@nextcloud/excalidraw/dist/types/excalidraw/types'
import { useExcalidrawStore } from './stores/useExcalidrawStore'
import { useWhiteboardConfigStore } from './stores/useWhiteboardConfigStore'
import { useThemeHandling } from './hooks/useThemeHandling'
@@ -54,6 +55,7 @@ import { VotingSidebar } from './components/VotingSidebar'
import { useVoting } from './hooks/useVoting'
import { useContextMenuFilter } from './hooks/useContextMenuFilter'
import { useDisableExternalLibraries } from './hooks/useDisableExternalLibraries'
+import { showError, showSuccess } from '@nextcloud/dialogs'
const Excalidraw = memo(ExcalidrawComponent)
@@ -61,6 +63,14 @@ const MemoizedNetworkStatusIndicator = memo(NetworkStatusIndicator)
const MemoizedAuthErrorNotification = memo(AuthErrorNotification)
const MemoizedExcalidrawMenu = memo(ExcalidrawMenu)
+type LoadLibraryForApi = (api: ExcalidrawImperativeAPI) => void
+type LibraryTemplateDialogSource = 'library' | 'selection'
+const LIBRARY_TEMPLATE_LOADED_STORAGE_KEY = 'whiteboard.libraryTemplateLoaded'
+
+function formatLibraryItemCount(count: number): string {
+ return n('whiteboard', '%n library item', '%n library items', count)
+}
+
export interface WhiteboardAppProps {
fileId: number
fileName: string
@@ -98,6 +108,18 @@ export default function App({
setExcalidrawAPI: state.setExcalidrawAPI,
resetExcalidrawAPI: state.resetExcalidrawAPI,
})))
+ const excalidrawAPIRef = useRef(null)
+ const loadLibraryForApiRef = useRef(() => {})
+ const handleExcalidrawAPI = useCallback((api: ExcalidrawImperativeAPI | null) => {
+ if (api) {
+ excalidrawAPIRef.current = api
+ setExcalidrawAPI(api)
+ loadLibraryForApiRef.current(api)
+ return
+ }
+ excalidrawAPIRef.current = null
+ resetExcalidrawAPI()
+ }, [resetExcalidrawAPI, setExcalidrawAPI])
const {
setConfig,
@@ -130,7 +152,21 @@ export default function App({
const { renderAssistant } = useAssistant()
const { renderEmojiPicker } = useEmojiPicker()
const { onChange: onChangeSync, onPointerUpdate } = useSync()
- const { fetchLibraryItems, updateLibraryItems, isLibraryLoaded, setIsLibraryLoaded } = useLibrary()
+ const {
+ fetchLibraryItems,
+ mergeInitialLibraryItems,
+ updateLibraryItems,
+ saveLibraryTemplate,
+ isLibraryLoaded,
+ setIsLibraryLoaded,
+ } = useLibrary()
+ const [libraryTemplateDialogItems, setLibraryTemplateDialogItems] = useState(null)
+ const [libraryTemplateDialogSource, setLibraryTemplateDialogSource] = useState('library')
+ const [libraryTemplateName, setLibraryTemplateName] = useState('')
+ const [libraryTemplateError, setLibraryTemplateError] = useState(null)
+ const [isSavingLibraryTemplate, setIsSavingLibraryTemplate] = useState(false)
+ const libraryTemplateNameInputRef = useRef(null)
+ const notifiedLibraryTemplateFileIdRef = useRef(null)
useCollaboration()
const { isReadOnly, refreshReadOnlyState } = useReadOnlyState()
@@ -285,10 +321,67 @@ export default function App({
}, [handleExternalRestore, normalizedFileId])
// Use the board data manager hook
- const { saveOnUnmount, isLoading } = useBoardDataManager()
+ const { saveOnUnmount, isLoading, getInitialLibraryItems, getInitialLibraryItemsPresent } = useBoardDataManager()
+
+ loadLibraryForApiRef.current = (api: ExcalidrawImperativeAPI) => {
+ if (isLoading) {
+ return
+ }
+
+ window.name = fileName
+ const loadLibraryItems = async () => {
+ try {
+ const initialLibraryItems = getInitialLibraryItems()
+ const hasInitialLibraryItems = getInitialLibraryItemsPresent() && initialLibraryItems.length > 0
+ const libraryItems = await fetchLibraryItems()
+ const mergedLibraryItems = mergeInitialLibraryItems(
+ libraryItems || [],
+ initialLibraryItems,
+ hasInitialLibraryItems,
+ )
+ await api.updateLibrary({
+ libraryItems: mergedLibraryItems,
+ merge: false,
+ })
+ if (hasInitialLibraryItems && !isVersionPreview) {
+ const notificationKey = `${LIBRARY_TEMPLATE_LOADED_STORAGE_KEY}.${normalizedFileId}`
+ let alreadyNotified = notifiedLibraryTemplateFileIdRef.current === normalizedFileId
+ try {
+ alreadyNotified = alreadyNotified || window.localStorage.getItem(notificationKey) === '1'
+ } catch {
+ // Ignore blocked storage. The notification is best-effort UI polish.
+ }
+ if (!alreadyNotified) {
+ api.toggleSidebar({ name: 'default', tab: 'library', force: true })
+ showSuccess(t('whiteboard', 'Library template loaded. {items} were added to the Library sidebar.', {
+ items: formatLibraryItemCount(initialLibraryItems.length),
+ }))
+ notifiedLibraryTemplateFileIdRef.current = normalizedFileId
+ try {
+ window.localStorage.setItem(notificationKey, '1')
+ } catch {
+ // Ignore blocked storage. The in-memory guard still prevents duplicate toasts this load.
+ }
+ }
+ }
+ setIsLibraryLoaded(true)
+ } catch (error) {
+ logger.error('[App] Error updating library items:', error)
+ }
+ }
+ loadLibraryItems()
+ }
+
+ useEffect(() => {
+ if (!excalidrawAPI || isLoading || isLibraryLoaded) {
+ return
+ }
+ loadLibraryForApiRef.current(excalidrawAPI)
+ }, [excalidrawAPI, isLoading, isLibraryLoaded])
// Effect to handle fileId changes - cleanup previous board data
useEffect(() => {
+ setIsLibraryLoaded(false)
// Clear any existing Excalidraw data when fileId changes
if (excalidrawAPI) {
excalidrawAPI.resetScene()
@@ -306,41 +399,18 @@ export default function App({
}, [normalizedFileId, excalidrawAPI, resetInitialDataPromise, saveOnUnmount])
useEffect(() => {
- resetInitialDataPromise()
-
- // Fetch library items from the API
- window.name = fileName
- const fetchLibInterval = setInterval(async () => {
- const api = useExcalidrawStore.getState().excalidrawAPI
- if (!api) {
- logger.warn('[App] Excalidraw API not available, cannot update library')
- return
- }
- clearInterval(fetchLibInterval)
- try {
- const libraryItems = await fetchLibraryItems()
- await api.updateLibrary({
- libraryItems: libraryItems || [],
- })
- setIsLibraryLoaded(true)
- } catch (error) {
- logger.error('[App] Error updating library items:', error)
- }
- }, 1000)
-
- // On unmount: Clean up all stores to prevent stale state
return () => {
- // Save any pending changes before resetting stores
saveOnUnmount()
-
- // Reset all stores
resetStore()
resetExcalidrawAPI()
-
- // Terminate the worker
terminateWorker()
}
- }, [resetInitialDataPromise, resetStore, resetExcalidrawAPI, terminateWorker, saveOnUnmount])
+ }, [
+ resetStore,
+ resetExcalidrawAPI,
+ terminateWorker,
+ saveOnUnmount,
+ ])
const [activeCommentThreadId, setActiveCommentThreadId] = useState(null)
const [commentSidebarDocked, setCommentSidebarDocked] = useState(false)
@@ -397,11 +467,98 @@ export default function App({
return
}
try {
- await updateLibraryItems(items)
+ await updateLibraryItems(items, normalizedFileId, getInitialLibraryItemsPresent())
} catch (error) {
logger.error('[App] Error syncing library items:', error)
}
- }, [isLibraryLoaded])
+ }, [getInitialLibraryItemsPresent, isLibraryLoaded, normalizedFileId, updateLibraryItems])
+
+ useEffect(() => {
+ if (!libraryTemplateDialogItems) {
+ return
+ }
+ requestAnimationFrame(() => libraryTemplateNameInputRef.current?.focus())
+ }, [libraryTemplateDialogItems])
+
+ const onLibrarySaveAsTemplate = useCallback((items: LibraryItems, context?: { source?: LibraryTemplateDialogSource }) => {
+ if (items.length === 0) {
+ showError(t('whiteboard', 'Add items to your library before saving a library template'))
+ return
+ }
+
+ setLibraryTemplateDialogSource(context?.source === 'selection' ? 'selection' : 'library')
+ setLibraryTemplateName('')
+ setLibraryTemplateError(null)
+ setLibraryTemplateDialogItems(items)
+ }, [])
+
+ const closeLibraryTemplateDialog = useCallback(() => {
+ if (isSavingLibraryTemplate) {
+ return
+ }
+ setLibraryTemplateDialogItems(null)
+ setLibraryTemplateDialogSource('library')
+ setLibraryTemplateName('')
+ setLibraryTemplateError(null)
+ }, [isSavingLibraryTemplate])
+
+ const submitLibraryTemplateDialog = useCallback(async (event?: FormEvent) => {
+ event?.preventDefault()
+ if (!libraryTemplateDialogItems || isSavingLibraryTemplate) {
+ return
+ }
+
+ const templateName = libraryTemplateName.trim()
+ if (!templateName) {
+ setLibraryTemplateError(t('whiteboard', 'Library template name is required'))
+ return
+ }
+
+ setIsSavingLibraryTemplate(true)
+ setLibraryTemplateError(null)
+ try {
+ await saveLibraryTemplate(templateName, libraryTemplateDialogItems)
+ const libraryItems = await fetchLibraryItems()
+ await (excalidrawAPI ?? excalidrawAPIRef.current)?.updateLibrary({
+ libraryItems: mergeInitialLibraryItems(
+ libraryItems || [],
+ getInitialLibraryItems(),
+ getInitialLibraryItemsPresent(),
+ ),
+ merge: false,
+ })
+ setLibraryTemplateDialogItems(null)
+ setLibraryTemplateDialogSource('library')
+ setLibraryTemplateName('')
+ showSuccess(t('whiteboard', 'Saved "{name}" as a library template with {items}.', {
+ name: templateName,
+ items: formatLibraryItemCount(libraryTemplateDialogItems.length),
+ }))
+ } catch (error: any) {
+ if (error?.status === 409) {
+ const conflictMessage = t('whiteboard', 'A library template with this name or the same items already exists')
+ setLibraryTemplateError(conflictMessage)
+ showError(conflictMessage)
+ return
+ }
+ logger.error('[App] Error saving library template:', error)
+ const errorMessage = t('whiteboard', 'Could not save library template')
+ setLibraryTemplateError(errorMessage)
+ showError(errorMessage)
+ } finally {
+ setIsSavingLibraryTemplate(false)
+ }
+ }, [
+ excalidrawAPI,
+ fetchLibraryItems,
+ getInitialLibraryItems,
+ getInitialLibraryItemsPresent,
+ isSavingLibraryTemplate,
+ libraryTemplateDialogItems,
+ libraryTemplateName,
+ mergeInitialLibraryItems,
+ saveLibraryTemplate,
+ ])
const libraryReturnUrl = encodeURIComponent(window.location.href)
@@ -524,7 +681,8 @@ export default function App({
validateEmbeddable={() => true}
renderEmbeddable={Embeddable}
beforeElementCreated={beforeElementCreated}
- excalidrawAPI={setExcalidrawAPI}
+ excalidrawAPI={handleExcalidrawAPI}
+ onExcalidrawAPI={handleExcalidrawAPI}
initialData={initialDataPromise}
generateIdForFile={generateIdForFile}
onPointerUpdate={onPointerUpdate}
@@ -539,6 +697,7 @@ export default function App({
}}
onLinkOpen={onLinkOpen}
onLibraryChange={onLibraryChange}
+ onLibrarySaveAsTemplate={onLibrarySaveAsTemplate}
langCode={lang}
libraryReturnUrl={libraryReturnUrl}
>
@@ -616,6 +775,68 @@ export default function App({
settings={creatorDisplaySettings}
/>
)}
+ {libraryTemplateDialogItems && (
+
+ )}
)
diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue
index 9828fc68..3404687a 100644
--- a/src/components/AdminSettings.vue
+++ b/src/components/AdminSettings.vue
@@ -49,6 +49,46 @@
+
+
+ {{ t('whiteboard', 'Upload .excalidrawlib files to make reusable library items available when users create a new whiteboard. New boards start with an empty canvas and copied library items. Changes affect only future whiteboards.') }}
+
+
+
+ {{ uploadingGlobalLibraryTemplate ? t('whiteboard', 'Uploading…') : t('whiteboard', 'Upload library template') }}
+
+
+
+
+
+
+ {{ t('whiteboard', 'Loading organization library templates…') }}
+
+
+ {{ t('whiteboard', 'No organization library templates yet. Upload an .excalidrawlib file to let users start new whiteboards with reusable library items.') }}
+
+
+
diff --git a/src/hooks/useBoardDataManager.ts b/src/hooks/useBoardDataManager.ts
index 699c11b6..52f8de25 100644
--- a/src/hooks/useBoardDataManager.ts
+++ b/src/hooks/useBoardDataManager.ts
@@ -18,10 +18,78 @@ import logger from '../utils/logger'
import { computeElementVersionHash, mergeSceneElements } from '../utils/syncSceneData'
import { sanitizeAppStateForSync } from '../utils/sanitizeAppState'
+const VOLATILE_ELEMENT_KEYS = new Set([
+ 'id',
+ 'seed',
+ 'version',
+ 'versionNonce',
+ 'updated',
+ 'index',
+ 'groupIds',
+ 'frameId',
+ 'boundElements',
+ 'containerId',
+])
+
+function canonicalizeLibraryValue(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map(canonicalizeLibraryValue)
+ }
+ if (value && typeof value === 'object') {
+ const normalized: Record = {}
+ for (const key of Object.keys(value as Record).sort()) {
+ if (VOLATILE_ELEMENT_KEYS.has(key)) {
+ continue
+ }
+ normalized[key] = canonicalizeLibraryValue((value as Record)[key])
+ }
+ return normalized
+ }
+ return value
+}
+
+function sanitizeLibraryItems(items: unknown): any[] {
+ if (!Array.isArray(items)) {
+ return []
+ }
+
+ const sanitized: any[] = []
+ const seen = new Set()
+
+ for (const item of items) {
+ if (!item || typeof item !== 'object' || !Array.isArray((item as any).elements) || (item as any).elements.length === 0) {
+ continue
+ }
+
+ const cleanItem = { ...(item as any) }
+ delete cleanItem.templateName
+ delete cleanItem.scope
+ delete cleanItem.filename
+ delete cleanItem.basename
+ cleanItem.elements = [...cleanItem.elements]
+
+ let key = ''
+ try {
+ key = JSON.stringify(canonicalizeLibraryValue(cleanItem.elements))
+ } catch {
+ key = cleanItem.id || ''
+ }
+ if (!key || seen.has(key)) {
+ continue
+ }
+ seen.add(key)
+ sanitized.push(cleanItem)
+ }
+
+ return sanitized
+}
+
export function useBoardDataManager() {
const [isLoading, setIsLoading] = useState(true)
const loadingTimeoutsRef = useRef>(new Set())
const currentFileIdRef = useRef(null)
+ const initialLibraryItemsRef = useRef([])
+ const initialLibraryItemsPresentRef = useRef(false)
const {
fileId,
@@ -82,6 +150,8 @@ export function useBoardDataManager() {
}, [])
const loadBoard = useCallback(async () => {
+ initialLibraryItemsRef.current = []
+ initialLibraryItemsPresentRef.current = false
if (isVersionPreview) {
try {
if (!versionSource) {
@@ -139,6 +209,11 @@ export function useBoardDataManager() {
...sanitizedAppState,
}
+ const libraryItemsPresent = Array.isArray(parsedContent.libraryItems)
+ const libraryItems = sanitizeLibraryItems(parsedContent.libraryItems)
+ initialLibraryItemsRef.current = libraryItems
+ initialLibraryItemsPresentRef.current = libraryItemsPresent
+
resolveInitialData({
elements: parsedContent.elements,
files: parsedContent.files || {},
@@ -210,6 +285,7 @@ export function useBoardDataManager() {
dataToUse = {
elements: reconciledElements,
files: mergedFiles,
+ libraryItems: sanitizeLibraryItems(serverData.libraryItems),
appState: mergedAppState,
scrollToContent: serverScrollToContent,
}
@@ -266,6 +342,11 @@ export function useBoardDataManager() {
const sanitizedAppState = sanitizeAppStateForSync(dataToUse.appState)
const finalAppState = { ...defaultSettings, ...sanitizedAppState }
const files = dataToUse.files || {}
+ const libraryItemsPresent = Object.prototype.hasOwnProperty.call(dataToUse, 'libraryItems')
+ && Array.isArray(dataToUse.libraryItems)
+ const libraryItems = sanitizeLibraryItems(dataToUse.libraryItems)
+ initialLibraryItemsRef.current = libraryItems
+ initialLibraryItemsPresentRef.current = libraryItemsPresent
// Force a small delay to ensure the component is ready to receive the data
const timeout = setTimeout(() => {
@@ -283,6 +364,8 @@ export function useBoardDataManager() {
}, 50)
loadingTimeoutsRef.current.add(timeout)
} else {
+ initialLibraryItemsRef.current = []
+ initialLibraryItemsPresentRef.current = false
// No valid data from either source, use defaults
// Force a small delay to ensure the component is ready to receive the data
const timeout = setTimeout(() => {
@@ -301,6 +384,8 @@ export function useBoardDataManager() {
const timeout = setTimeout(() => {
// Validate one more time before resolving
if (currentFileIdRef.current === fileId) {
+ initialLibraryItemsRef.current = []
+ initialLibraryItemsPresentRef.current = false
resolveInitialData(initialDataState)
setIsLoading(false)
}
@@ -396,9 +481,14 @@ export function useBoardDataManager() {
}
}, [cancelPendingTimeouts])
+ const getInitialLibraryItems = useCallback(() => initialLibraryItemsRef.current, [])
+ const getInitialLibraryItemsPresent = useCallback(() => initialLibraryItemsPresentRef.current, [])
+
return {
isLoading,
loadBoard,
saveOnUnmount,
+ getInitialLibraryItems,
+ getInitialLibraryItemsPresent,
}
}
diff --git a/src/hooks/useLibrary.ts b/src/hooks/useLibrary.ts
index 7721c7ab..c3b97990 100644
--- a/src/hooks/useLibrary.ts
+++ b/src/hooks/useLibrary.ts
@@ -3,15 +3,126 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { useCallback, useState } from 'react'
+import { useCallback, useRef, useState } from 'react'
import { useJWTStore } from '../stores/useJwtStore'
import { useShallow } from 'zustand/react/shallow'
import { generateUrl } from '@nextcloud/router'
-import type { LibraryItem, LibraryItems } from '@excalidraw/excalidraw/types/types'
+import type { LibraryItem, LibraryItems } from '@nextcloud/excalidraw/dist/types/excalidraw/types'
import logger from '../utils/logger'
-type LibraryItemExtended = LibraryItem & {
- filename?: string;
+type LibraryTemplate = {
+ templateName: string
+ items: LibraryItem[]
+}
+
+type LibraryItemContext = {
+ templateName: string
+}
+
+type LibrarySaveError = Error & {
+ status?: number
+}
+
+const PERSONAL_TEMPLATE = 'personal'
+const BOARD_TEMPLATE = '__board_template__'
+const VOLATILE_ELEMENT_KEYS = new Set([
+ 'id',
+ 'seed',
+ 'version',
+ 'versionNonce',
+ 'updated',
+ 'index',
+ 'groupIds',
+ 'frameId',
+ 'boundElements',
+ 'containerId',
+])
+
+function cleanLibraryItem(item: LibraryItem): LibraryItem {
+ const cleanItem = { ...item } as LibraryItem & Record
+ delete cleanItem.templateName
+ delete cleanItem.scope
+ delete cleanItem.filename
+ delete cleanItem.basename
+ return cleanItem
+}
+
+function canonicalizeLibraryValue(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map(canonicalizeLibraryValue)
+ }
+ if (value && typeof value === 'object') {
+ const normalized: Record = {}
+ for (const key of Object.keys(value as Record).sort()) {
+ if (VOLATILE_ELEMENT_KEYS.has(key)) {
+ continue
+ }
+ normalized[key] = canonicalizeLibraryValue((value as Record)[key])
+ }
+ return normalized
+ }
+ return value
+}
+
+function getLibraryItemContentKey(item: LibraryItem): string {
+ try {
+ return JSON.stringify(canonicalizeLibraryValue(item.elements ?? []))
+ } catch {
+ return item.id || ''
+ }
+}
+
+function dedupeLibraryItems(items: LibraryItems): LibraryItem[] {
+ const deduped: LibraryItem[] = []
+ const seen = new Set()
+
+ for (const item of items) {
+ const key = getLibraryItemContentKey(item)
+ if (!key || seen.has(key)) {
+ continue
+ }
+ seen.add(key)
+ deduped.push(cleanLibraryItem(item))
+ }
+
+ return deduped
+}
+
+function normalizeTemplates(responseData: unknown): LibraryTemplate[] {
+ const templates = (responseData as { data?: { templates?: unknown } })?.data?.templates
+ if (!Array.isArray(templates)) {
+ return []
+ }
+
+ return templates
+ .filter((template): template is { templateName: string; scope?: string; items: LibraryItem[] } => {
+ const candidate = template as { templateName?: unknown; items?: unknown }
+ return typeof candidate.templateName === 'string' && Array.isArray(candidate.items)
+ })
+ .map(template => ({
+ templateName: template.templateName,
+ items: template.items,
+ }))
+}
+
+function updateItemContextMap(templates: LibraryTemplate[], itemContexts: Map) {
+ for (const template of templates) {
+ for (const item of template.items) {
+ if (!item.id) {
+ continue
+ }
+ const existing = itemContexts.get(item.id)
+ if (existing?.templateName === PERSONAL_TEMPLATE && template.templateName !== PERSONAL_TEMPLATE) {
+ continue
+ }
+ if (existing && existing.templateName !== PERSONAL_TEMPLATE && template.templateName !== PERSONAL_TEMPLATE) {
+ continue
+ }
+ itemContexts.set(item.id, {
+ templateName: template.templateName,
+ })
+ }
+ }
}
export function useLibrary() {
@@ -22,6 +133,7 @@ export function useLibrary() {
)
const [isLibraryLoaded, setIsLibraryLoaded] = useState(false)
+ const itemContextsRef = useRef>(new Map())
const fetchLibraryItems = useCallback(async (): Promise => {
try {
@@ -45,60 +157,128 @@ export function useLibrary() {
}
const data = await response.json()
- const libraryItems: LibraryItems = []
+ const templates = normalizeTemplates(data)
+ const personalTemplates = templates.filter(template => template.templateName.toLowerCase() === PERSONAL_TEMPLATE)
+ const personalItems = dedupeLibraryItems(personalTemplates.flatMap(template => template.items))
+ const itemContexts = new Map()
+ updateItemContextMap([{
+ templateName: PERSONAL_TEMPLATE,
+ items: personalItems,
+ }], itemContexts)
+ itemContextsRef.current = itemContexts
+
+ return personalItems
+ } catch (error) {
+ logger.error('[Library] Error fetching library:', error)
+ return null
+ }
+ }, [getJWT])
- for (const file of data.data) {
- if (!file.library && !file.libraryItems) {
+ const mergeInitialLibraryItems = useCallback((personalItems: LibraryItems, currentItems: LibraryItems, useBoardLibrary = false): LibraryItems => {
+ const merged: LibraryItem[] = []
+ const seen = new Set()
+ const nextContexts = new Map()
+
+ if (useBoardLibrary) {
+ for (const item of dedupeLibraryItems(currentItems)) {
+ const key = getLibraryItemContentKey(item)
+ if (!key || seen.has(key)) {
continue
}
-
- const date = new Date()
-
- // Handle for version 1 (legacy library files from https://excalidraw.com)
- if (file.library) {
- for (const elements of file.library) {
- const item: LibraryItemExtended = {
- id: '',
- created: date.getTime(),
- status: 'published',
- elements,
- filename: file.filename,
- }
- libraryItems.push(item)
- }
+ seen.add(key)
+ merged.push(item)
+ if (item.id) {
+ nextContexts.set(item.id, { templateName: BOARD_TEMPLATE })
}
+ }
+ itemContextsRef.current = nextContexts
+ return merged
+ }
- // Handle for version 2
- if (file.libraryItems) {
- for (const item of file.libraryItems) {
- if (!item.elements || item.elements.length === 0) {
- continue
- }
- const libraryItem: LibraryItemExtended = {
- id: item.id,
- created: item.created || date.getTime(),
- status: item.status || 'unpublished',
- elements: item.elements,
- filename: file.filename,
- }
- libraryItems.push(libraryItem)
- }
- }
+ for (const item of dedupeLibraryItems(personalItems)) {
+ const key = getLibraryItemContentKey(item)
+ seen.add(key)
+ merged.push(item)
+ if (item.id) {
+ nextContexts.set(item.id, { templateName: PERSONAL_TEMPLATE })
}
- return libraryItems
- } catch (error) {
- logger.error('[Library] Error fetching library:', error)
- return null
}
- })
- const updateLibraryItems = useCallback(async (items: LibraryItems): Promise => {
+ for (const item of dedupeLibraryItems(currentItems)) {
+ const key = getLibraryItemContentKey(item)
+ if (!key || seen.has(key)) {
+ continue
+ }
+ seen.add(key)
+ merged.push(item)
+ if (item.id) {
+ nextContexts.set(item.id, { templateName: BOARD_TEMPLATE })
+ }
+ }
+
+ itemContextsRef.current = nextContexts
+ return merged
+ }, [])
+
+ const updateLibraryItems = useCallback(async (items: LibraryItems, boardFileId?: number | null, useBoardLibrary = false): Promise => {
try {
const jwt = await getJWT()
if (!jwt) {
logger.warn('[Library] No JWT found, cannot update library')
return
}
+
+ if (useBoardLibrary && boardFileId) {
+ const boardItems = dedupeLibraryItems(items)
+ const url = generateUrl(`apps/whiteboard/${boardFileId}`)
+ const response = await globalThis.fetch(url, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ Authorization: `Bearer ${jwt}`,
+ },
+ body: JSON.stringify({
+ data: {
+ libraryItems: boardItems,
+ },
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to update board library: ${response.statusText}`)
+ }
+
+ const nextContexts = new Map()
+ updateItemContextMap([{
+ templateName: BOARD_TEMPLATE,
+ items: boardItems,
+ }], nextContexts)
+ itemContextsRef.current = nextContexts
+ return
+ }
+
+ const personalItems: LibraryItem[] = []
+ const seen = new Set()
+ for (const item of items) {
+ const context = item.id ? itemContextsRef.current.get(item.id) : undefined
+ if (context && context.templateName !== PERSONAL_TEMPLATE) {
+ continue
+ }
+ const cleanItem = cleanLibraryItem(item)
+ const key = getLibraryItemContentKey(cleanItem)
+ if (!key || seen.has(key)) {
+ continue
+ }
+ seen.add(key)
+ personalItems.push(cleanItem)
+ }
+
+ const templates = [{
+ templateName: PERSONAL_TEMPLATE,
+ items: personalItems,
+ }]
+
const url = generateUrl('apps/whiteboard/library')
const response = await globalThis.fetch(url, {
method: 'PUT',
@@ -107,20 +287,59 @@ export function useLibrary() {
'X-Requested-With': 'XMLHttpRequest',
Authorization: `Bearer ${jwt}`,
},
- body: JSON.stringify({ items }),
+ body: JSON.stringify({ templates }),
})
if (!response.ok) {
throw new Error(`Failed to update library: ${response.statusText}`)
}
+
+ const nextContexts = new Map(
+ Array.from(itemContextsRef.current.entries()).filter(([, context]) => context.templateName !== PERSONAL_TEMPLATE),
+ )
+ updateItemContextMap(templates.map(template => ({
+ templateName: template.templateName,
+ items: template.items,
+ })), nextContexts)
+ itemContextsRef.current = nextContexts
} catch (error) {
logger.error('[Library] Error updating library:', error)
}
- })
+ }, [getJWT])
+
+ const saveLibraryTemplate = useCallback(async (templateName: string, items: LibraryItems): Promise => {
+ const jwt = await getJWT()
+ if (!jwt) {
+ logger.warn('[Library] No JWT found, cannot save library template')
+ return
+ }
+
+ const url = generateUrl('apps/whiteboard/library/template')
+ const response = await globalThis.fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ Authorization: `Bearer ${jwt}`,
+ },
+ body: JSON.stringify({
+ templateName,
+ items: items.map(cleanLibraryItem),
+ }),
+ })
+
+ if (!response.ok) {
+ const error = new Error(`Failed to save library template: ${response.statusText}`) as LibrarySaveError
+ error.status = response.status
+ throw error
+ }
+ }, [getJWT])
return {
fetchLibraryItems,
+ mergeInitialLibraryItems,
updateLibraryItems,
+ saveLibraryTemplate,
isLibraryLoaded,
setIsLibraryLoaded,
}
diff --git a/src/styles/globals/_layout.scss b/src/styles/globals/_layout.scss
index 7e305384..2f166706 100644
--- a/src/styles/globals/_layout.scss
+++ b/src/styles/globals/_layout.scss
@@ -237,6 +237,108 @@
}
}
+.library-template-dialog__backdrop {
+ position: absolute;
+ inset: 0;
+ z-index: 100021;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ background: rgba(0, 0, 0, 0.35);
+}
+
+.library-template-dialog {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ width: min(420px, 100%);
+ padding: 20px;
+ color: var(--color-main-text);
+ background: var(--color-main-background);
+ border-radius: var(--border-radius-large);
+ box-shadow: 0 4px 18px var(--color-box-shadow);
+
+ h2 {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 700;
+ line-height: 1.3;
+ }
+
+ p {
+ margin: 0;
+ }
+
+ label {
+ font-weight: 600;
+ }
+
+ input {
+ width: 100%;
+ min-height: 38px;
+ padding: 8px 10px;
+ color: var(--color-main-text);
+ background: var(--color-main-background);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius);
+
+ &:focus-visible {
+ @include tokens.focus-ring();
+ }
+ }
+}
+
+.library-template-dialog__hint,
+.library-template-dialog__count {
+ color: var(--color-text-maxcontrast);
+ line-height: 1.4;
+}
+
+.library-template-dialog__error {
+ margin: 0;
+ color: var(--color-error);
+}
+
+.library-template-dialog__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.library-template-dialog__button {
+ min-height: 34px;
+ padding: 6px 14px;
+ color: var(--color-main-text);
+ background: var(--color-background-hover);
+ border: none;
+ border-radius: var(--border-radius);
+ cursor: pointer;
+
+ &:hover {
+ background: var(--color-background-dark);
+ }
+
+ &:focus-visible {
+ @include tokens.focus-ring();
+ }
+
+ &:disabled {
+ cursor: default;
+ opacity: 0.6;
+ }
+}
+
+.library-template-dialog__button--primary {
+ color: var(--color-primary-text);
+ background: var(--color-primary);
+
+ &:hover {
+ background: var(--color-primary-element-hover);
+ }
+}
+
.version-preview-banner {
position: absolute;
top: calc(var(--default-grid-baseline) * 2);
diff --git a/src/utils/sanitizeAppState.ts b/src/utils/sanitizeAppState.ts
index a05f8572..9e9252e8 100644
--- a/src/utils/sanitizeAppState.ts
+++ b/src/utils/sanitizeAppState.ts
@@ -12,6 +12,7 @@ const NON_TRANSFERRED_KEYS: Array = [
'height',
'offsetTop',
'offsetLeft',
+ 'searchMatches',
]
export function sanitizeAppStateForSync(state: Partial | AppState | null | undefined): Partial {
diff --git a/tests/Unit/AppInfo/ApplicationTest.php b/tests/Unit/AppInfo/ApplicationTest.php
index be02ba36..008b1f8f 100644
--- a/tests/Unit/AppInfo/ApplicationTest.php
+++ b/tests/Unit/AppInfo/ApplicationTest.php
@@ -10,10 +10,17 @@
namespace OCA\Whiteboard\AppInfo;
+use OCA\Whiteboard\Template\GlobalLibraryTemplateProvider;
+use OCP\AppFramework\Bootstrap\IRegistrationContext;
+
class ApplicationTest extends \Test\TestCase {
public function testApp(): void {
- $registrationContext = $this->createMock(\OCP\AppFramework\Bootstrap\IRegistrationContext::class);
+ $registrationContext = $this->createMock(IRegistrationContext::class);
+ $registrationContext->expects($this->once())
+ ->method('registerTemplateProvider')
+ ->with(GlobalLibraryTemplateProvider::class);
+
$app = new Application();
$app->register($registrationContext);
self::assertTrue(true);
diff --git a/tests/Unit/Listener/FileCreatedFromTemplateListenerTest.php b/tests/Unit/Listener/FileCreatedFromTemplateListenerTest.php
new file mode 100644
index 00000000..3865e48e
--- /dev/null
+++ b/tests/Unit/Listener/FileCreatedFromTemplateListenerTest.php
@@ -0,0 +1,82 @@
+createMock(File::class);
+ $template->method('getName')->willReturn('Flowchart.excalidrawlib');
+ $template->method('getPath')->willReturn('/appdata/whiteboard/global-libraries/Flowchart.excalidrawlib');
+ $template->method('getContent')->willReturn(json_encode([
+ 'type' => 'excalidrawlib',
+ 'version' => 2,
+ 'libraryItems' => [
+ [
+ 'id' => 'item-1',
+ 'status' => 'published',
+ 'elements' => [
+ ['id' => 'element-1', 'type' => 'rectangle'],
+ ],
+ ],
+ ],
+ ], JSON_THROW_ON_ERROR));
+
+ $target = $this->createMock(File::class);
+ $target->method('getName')->willReturn('New whiteboard.whiteboard');
+ $target->method('getPath')->willReturn('/admin/files/New whiteboard.whiteboard');
+ $target->expects($this->once())
+ ->method('putContent')
+ ->with($this->callback(static function (string $content): bool {
+ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ return $data['elements'] === []
+ && $data['files'] === []
+ && $data['scrollToContent'] === true
+ && count($data['libraryItems']) === 1
+ && $data['libraryItems'][0]['id'] === 'item-1';
+ }));
+
+ $listener = new FileCreatedFromTemplateListener($this->createMock(LoggerInterface::class));
+ $listener->handle(new FileCreatedFromTemplateEvent($template, $target, []));
+ }
+
+ public function testAcceptsLegacyLibraryFormat(): void {
+ $template = $this->createMock(File::class);
+ $template->method('getName')->willReturn('Legacy.excalidrawlib');
+ $template->method('getPath')->willReturn('/appdata/whiteboard/global-libraries/Legacy.excalidrawlib');
+ $template->method('getContent')->willReturn(json_encode([
+ 'type' => 'excalidrawlib',
+ 'library' => [
+ [
+ ['id' => 'element-1', 'type' => 'diamond'],
+ ],
+ ],
+ ], JSON_THROW_ON_ERROR));
+
+ $target = $this->createMock(File::class);
+ $target->method('getName')->willReturn('New whiteboard.whiteboard');
+ $target->method('getPath')->willReturn('/admin/files/New whiteboard.whiteboard');
+ $target->expects($this->once())
+ ->method('putContent')
+ ->with($this->callback(static function (string $content): bool {
+ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ return count($data['libraryItems']) === 1
+ && $data['libraryItems'][0]['status'] === 'published'
+ && $data['libraryItems'][0]['elements'][0]['type'] === 'diamond';
+ }));
+
+ $listener = new FileCreatedFromTemplateListener($this->createMock(LoggerInterface::class));
+ $listener->handle(new FileCreatedFromTemplateEvent($template, $target, []));
+ }
+}
diff --git a/vite.config.ts b/vite.config.ts
index 459a2af6..a487dfde 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -16,7 +16,20 @@ const AppConfig = createAppConfig({
}, {
config: defineConfig({
resolve: {
+ dedupe: ['react', 'react-dom'],
alias: [
+ {
+ find: 'vite-plugin-node-polyfills/shims/buffer',
+ replacement: resolve('node_modules/vite-plugin-node-polyfills/shims/buffer/dist/index.js'),
+ },
+ {
+ find: 'vite-plugin-node-polyfills/shims/global',
+ replacement: resolve('node_modules/vite-plugin-node-polyfills/shims/global/dist/index.js'),
+ },
+ {
+ find: 'vite-plugin-node-polyfills/shims/process',
+ replacement: resolve('node_modules/vite-plugin-node-polyfills/shims/process/dist/index.js'),
+ },
{
find: /^@excalidraw\/element(.*)$/,
replacement: '@nextcloud/excalidraw-element$1',