Skip to content

Commit 246784c

Browse files
committed
Implement whiteboard library templates
Signed-off-by: Hoang Pham <hoangmaths96@gmail.com>
1 parent 6782211 commit 246784c

18 files changed

Lines changed: 2033 additions & 153 deletions

appinfo/routes.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
['name' => 'Whiteboard#getLib', 'url' => 'library', 'verb' => 'GET'],
2424
/** @see WhiteboardController::updateLib() */
2525
['name' => 'Whiteboard#updateLib', 'url' => 'library', 'verb' => 'PUT'],
26+
/** @see WhiteboardController::saveLibTemplate() */
27+
['name' => 'Whiteboard#saveLibTemplate', 'url' => 'library/template', 'verb' => 'POST'],
2628
/** @see WhiteboardController::update() */
2729
['name' => 'Whiteboard#update', 'url' => '{fileId}', 'verb' => 'PUT'],
2830
/** @see WhiteboardController::show() */
@@ -33,6 +35,12 @@
3335
['name' => 'Recording#upload', 'url' => 'recording/{fileId}/upload', 'verb' => 'POST'],
3436
/** @see SettingsController::update() */
3537
['name' => 'Settings#update', 'url' => 'settings', 'verb' => 'POST'],
38+
/** @see SettingsController::listGlobalLibraryTemplates() */
39+
['name' => 'Settings#listGlobalLibraryTemplates', 'url' => 'settings/global-library', 'verb' => 'GET'],
40+
/** @see SettingsController::uploadGlobalLibraryTemplate() */
41+
['name' => 'Settings#uploadGlobalLibraryTemplate', 'url' => 'settings/global-library', 'verb' => 'POST'],
42+
/** @see SettingsController::deleteGlobalLibraryTemplate() */
43+
['name' => 'Settings#deleteGlobalLibraryTemplate', 'url' => 'settings/global-library/{templateName}', 'verb' => 'DELETE'],
3644
/** @see SettingsController::updatePersonal() */
3745
['name' => 'Settings#updatePersonal', 'url' => 'settings/personal', 'verb' => 'POST'],
3846
]

lib/AppInfo/Application.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,17 @@
1414
use OCA\Viewer\Event\LoadViewer;
1515
use OCA\Whiteboard\Listener\AddContentSecurityPolicyListener;
1616
use OCA\Whiteboard\Listener\BeforeTemplateRenderedListener;
17+
use OCA\Whiteboard\Listener\FileCreatedFromTemplateListener;
1718
use OCA\Whiteboard\Listener\LoadTextEditorListener;
1819
use OCA\Whiteboard\Listener\LoadViewerListener;
1920
use OCA\Whiteboard\Listener\RegisterTemplateCreatorListener;
2021
use OCA\Whiteboard\Settings\SetupCheck;
22+
use OCA\Whiteboard\Template\GlobalLibraryTemplateProvider;
2123
use OCP\AppFramework\App;
2224
use OCP\AppFramework\Bootstrap\IBootContext;
2325
use OCP\AppFramework\Bootstrap\IBootstrap;
2426
use OCP\AppFramework\Bootstrap\IRegistrationContext;
27+
use OCP\Files\Template\FileCreatedFromTemplateEvent;
2528
use OCP\Files\Template\ITemplateManager;
2629
use OCP\Files\Template\RegisterTemplateCreatorEvent;
2730
use OCP\IL10N;
@@ -47,7 +50,12 @@ public function register(IRegistrationContext $context): void {
4750
$context->registerEventListener(LoadViewer::class, LoadViewerListener::class);
4851
$context->registerEventListener(LoadViewer::class, LoadTextEditorListener::class);
4952
$context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateCreatorListener::class);
53+
$context->registerEventListener(FileCreatedFromTemplateEvent::class, FileCreatedFromTemplateListener::class);
5054
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
55+
[$major] = Util::getVersion();
56+
if ($major >= 30) {
57+
$context->registerTemplateProvider(GlobalLibraryTemplateProvider::class);
58+
}
5159
$context->registerSetupCheck(SetupCheck::class);
5260
}
5361

lib/Controller/SettingsController.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
use OCA\Whiteboard\Service\ConfigService;
1414
use OCA\Whiteboard\Service\ExceptionService;
1515
use OCA\Whiteboard\Service\JWTService;
16+
use OCA\Whiteboard\Service\WhiteboardLibraryService;
1617
use OCA\Whiteboard\Settings\SetupCheck;
1718
use OCP\AppFramework\Controller;
19+
use OCP\AppFramework\Http;
1820
use OCP\AppFramework\Http\DataResponse;
1921
use OCP\IRequest;
2022
use OCP\IUserSession;
@@ -31,6 +33,7 @@ public function __construct(
3133
private ConfigService $configService,
3234
private SetupCheck $setupCheck,
3335
private IUserSession $userSession,
36+
private WhiteboardLibraryService $libraryService,
3437
) {
3538
parent::__construct('whiteboard', $request);
3639
}
@@ -91,4 +94,44 @@ public function updatePersonal(): DataResponse {
9194
return $this->exceptionService->handleException($e);
9295
}
9396
}
97+
98+
public function listGlobalLibraryTemplates(): DataResponse {
99+
try {
100+
return new DataResponse($this->libraryService->getGlobalTemplateMetadata());
101+
} catch (Exception $e) {
102+
return $this->exceptionService->handleException($e);
103+
}
104+
}
105+
106+
public function uploadGlobalLibraryTemplate(): DataResponse {
107+
try {
108+
$uploadedFile = $this->request->getUploadedFile('file');
109+
if (!is_array($uploadedFile) || !isset($uploadedFile['tmp_name'], $uploadedFile['name'])) {
110+
throw new Exception('No library template uploaded', Http::STATUS_BAD_REQUEST);
111+
}
112+
if (($uploadedFile['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
113+
throw new Exception('Library template upload failed', Http::STATUS_BAD_REQUEST);
114+
}
115+
116+
$content = file_get_contents($uploadedFile['tmp_name']);
117+
if ($content === false) {
118+
throw new Exception('Failed to read uploaded library template', Http::STATUS_BAD_REQUEST);
119+
}
120+
121+
return new DataResponse([
122+
'template' => $this->libraryService->saveGlobalTemplateFromUpload($uploadedFile['name'], $content),
123+
], Http::STATUS_CREATED);
124+
} catch (Exception $e) {
125+
return $this->exceptionService->handleException($e);
126+
}
127+
}
128+
129+
public function deleteGlobalLibraryTemplate(string $templateName): DataResponse {
130+
try {
131+
$this->libraryService->deleteGlobalTemplate($templateName);
132+
return new DataResponse(['status' => 'success']);
133+
} catch (Exception $e) {
134+
return $this->exceptionService->handleException($e);
135+
}
136+
}
94137
}

lib/Controller/WhiteboardController.php

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
namespace OCA\Whiteboard\Controller;
1111

1212
use Exception;
13+
use InvalidArgumentException;
1314
use OCA\Whiteboard\Exception\InvalidUserException;
1415
use OCA\Whiteboard\Exception\UnauthorizedException;
1516
use OCA\Whiteboard\Service\Authentication\GetUserFromIdServiceFactory;
@@ -110,8 +111,8 @@ public function update(int $fileId, array $data): DataResponse {
110111
public function getLib(): DataResponse {
111112
try {
112113
$jwt = $this->getJwtFromRequest();
113-
$this->jwtService->getUserIdFromJWT($jwt);
114-
$data = $this->libraryService->getUserLib();
114+
$userId = $this->jwtService->getUserIdFromJWT($jwt);
115+
$data = $this->libraryService->getTemplates($userId);
115116

116117
return new DataResponse(['data' => $data]);
117118
} catch (Exception $e) {
@@ -126,15 +127,39 @@ public function updateLib(): DataResponse {
126127
try {
127128
$jwt = $this->getJwtFromRequest();
128129
$userId = $this->jwtService->getUserIdFromJWT($jwt);
129-
$items = $this->request->getParam('items', []);
130-
$this->libraryService->updateUserLib($userId, $items);
130+
$templates = $this->request->getParam('templates');
131+
if (!is_array($templates)) {
132+
throw new InvalidArgumentException('Library templates payload is required', 400);
133+
}
134+
$this->libraryService->updateUserTemplates($userId, $templates);
131135

132136
return new DataResponse(['status' => 'success']);
133137
} catch (Exception $e) {
134138
return $this->exceptionService->handleException($e);
135139
}
136140
}
137141

142+
#[NoAdminRequired]
143+
#[NoCSRFRequired]
144+
#[PublicPage]
145+
public function saveLibTemplate(): DataResponse {
146+
try {
147+
$jwt = $this->getJwtFromRequest();
148+
$userId = $this->jwtService->getUserIdFromJWT($jwt);
149+
$templateName = $this->request->getParam('templateName', '');
150+
$items = $this->request->getParam('items', []);
151+
if (!is_string($templateName) || !is_array($items)) {
152+
throw new InvalidArgumentException('Invalid library template payload', 400);
153+
}
154+
155+
$template = $this->libraryService->saveUserTemplate($userId, $templateName, $items);
156+
157+
return new DataResponse(['status' => 'success', 'template' => $template]);
158+
} catch (Exception $e) {
159+
return $this->exceptionService->handleException($e);
160+
}
161+
}
162+
138163
private function getJwtFromRequest(): string {
139164
$authHeader = $this->request->getHeader('Authorization');
140165
if (sscanf($authHeader, 'Bearer %s', $jwt) !== 1) {
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
namespace OCA\Whiteboard\Listener;
9+
10+
use OCP\EventDispatcher\Event;
11+
use OCP\EventDispatcher\IEventListener;
12+
use OCP\Files\File;
13+
use OCP\Files\Template\FileCreatedFromTemplateEvent;
14+
use Psr\Log\LoggerInterface;
15+
16+
/** @template-implements IEventListener<FileCreatedFromTemplateEvent|Event> */
17+
/**
18+
* @psalm-suppress MissingTemplateParam
19+
*/
20+
final class FileCreatedFromTemplateListener implements IEventListener {
21+
private const LIB_EXTENSION = '.excalidrawlib';
22+
private const WHITEBOARD_EXTENSION = '.whiteboard';
23+
private const VOLATILE_ELEMENT_KEYS = [
24+
'id' => true,
25+
'seed' => true,
26+
'version' => true,
27+
'versionNonce' => true,
28+
'updated' => true,
29+
'index' => true,
30+
'groupIds' => true,
31+
'frameId' => true,
32+
'boundElements' => true,
33+
'containerId' => true,
34+
];
35+
36+
/**
37+
* @psalm-suppress PossiblyUnusedMethod
38+
*/
39+
public function __construct(
40+
private LoggerInterface $logger,
41+
) {
42+
}
43+
44+
#[\Override]
45+
public function handle(Event $event): void {
46+
if (!($event instanceof FileCreatedFromTemplateEvent)) {
47+
return;
48+
}
49+
50+
$template = $event->getTemplate();
51+
$target = $event->getTarget();
52+
if (!($template instanceof File)) {
53+
return;
54+
}
55+
56+
if (!$this->isLibraryTemplate($template) || !$this->isWhiteboardTarget($target)) {
57+
return;
58+
}
59+
60+
$libraryItems = $this->parseLibraryItems($template);
61+
if ($libraryItems === []) {
62+
return;
63+
}
64+
65+
try {
66+
$target->putContent(json_encode([
67+
'elements' => [],
68+
'files' => [],
69+
'libraryItems' => $libraryItems,
70+
'scrollToContent' => true,
71+
], JSON_THROW_ON_ERROR));
72+
} catch (\Throwable $e) {
73+
$this->logger->warning('Failed to normalize whiteboard created from library template', [
74+
'app' => 'whiteboard',
75+
'template' => $template->getPath(),
76+
'target' => $target->getPath(),
77+
'exception' => $e,
78+
]);
79+
}
80+
}
81+
82+
private function isLibraryTemplate(File $file): bool {
83+
return str_ends_with(strtolower($file->getName()), self::LIB_EXTENSION);
84+
}
85+
86+
private function isWhiteboardTarget(File $file): bool {
87+
return str_ends_with(strtolower($file->getName()), self::WHITEBOARD_EXTENSION);
88+
}
89+
90+
private function parseLibraryItems(File $file): array {
91+
try {
92+
$data = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR);
93+
} catch (\Throwable) {
94+
return [];
95+
}
96+
97+
if (!is_array($data)) {
98+
return [];
99+
}
100+
101+
if (isset($data['libraryItems']) && is_array($data['libraryItems'])) {
102+
return $this->normalizeLibraryItems($data['libraryItems']);
103+
}
104+
105+
if (isset($data['library']) && is_array($data['library'])) {
106+
$items = [];
107+
foreach ($data['library'] as $elements) {
108+
if (!is_array($elements) || count($elements) === 0) {
109+
continue;
110+
}
111+
$items[] = [
112+
'id' => $this->createLibraryItemId($elements),
113+
'elements' => array_values($elements),
114+
'status' => 'published',
115+
];
116+
}
117+
return $this->normalizeLibraryItems($items);
118+
}
119+
120+
return [];
121+
}
122+
123+
private function normalizeLibraryItems(array $items): array {
124+
$normalized = [];
125+
$seen = [];
126+
127+
foreach ($items as $item) {
128+
if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements']) || count($item['elements']) === 0) {
129+
continue;
130+
}
131+
132+
unset($item['templateName'], $item['scope'], $item['filename'], $item['basename']);
133+
$item['elements'] = array_values($item['elements']);
134+
$key = $this->createLibraryItemId($item['elements']);
135+
if (isset($seen[$key])) {
136+
continue;
137+
}
138+
$seen[$key] = true;
139+
140+
$item['id'] = isset($item['id']) && is_string($item['id']) && $item['id'] !== ''
141+
? $item['id']
142+
: $key;
143+
$item['created'] = isset($item['created']) && is_numeric($item['created'])
144+
? (int)$item['created']
145+
: $this->nowMs();
146+
$item['status'] = isset($item['status']) && is_string($item['status'])
147+
? $item['status']
148+
: 'unpublished';
149+
150+
$normalized[] = $item;
151+
}
152+
153+
return $normalized;
154+
}
155+
156+
private function createLibraryItemId(array $elements): string {
157+
$canonicalElements = $this->canonicalizeLibraryValue($elements);
158+
$encoded = json_encode($canonicalElements);
159+
return substr(hash('sha256', $encoded !== false ? $encoded : serialize($canonicalElements)), 0, 20);
160+
}
161+
162+
private function canonicalizeLibraryValue(mixed $value): mixed {
163+
if (!is_array($value)) {
164+
return $value;
165+
}
166+
167+
if ($this->isListArray($value)) {
168+
return array_map(fn ($item) => $this->canonicalizeLibraryValue($item), $value);
169+
}
170+
171+
ksort($value);
172+
$normalized = [];
173+
foreach ($value as $key => $item) {
174+
if (is_string($key) && isset(self::VOLATILE_ELEMENT_KEYS[$key])) {
175+
continue;
176+
}
177+
$normalized[$key] = $this->canonicalizeLibraryValue($item);
178+
}
179+
return $normalized;
180+
}
181+
182+
private function nowMs(): int {
183+
return (int)floor((float)microtime(true) * 1000.0);
184+
}
185+
186+
private function isListArray(array $value): bool {
187+
return $value === [] || array_keys($value) === range(0, count($value) - 1);
188+
}
189+
}

0 commit comments

Comments
 (0)