mirror of
https://github.com/nextcloud/richdocuments.git
synced 2025-12-17 13:08:43 +01:00
feat(AI): generate presentations with AI
Signed-off-by: Elizabeth Danzberger <elizabeth@elzody.dev>
This commit is contained in:
parent
4c15cfefd6
commit
53376d304e
13 changed files with 493 additions and 29 deletions
BIN
emptyTemplates/ai/pitch.odp
Normal file
BIN
emptyTemplates/ai/pitch.odp
Normal file
Binary file not shown.
BIN
emptyTemplates/ai/security.odp
Normal file
BIN
emptyTemplates/ai/security.odp
Normal file
Binary file not shown.
BIN
emptyTemplates/ai/triangle.odp
Normal file
BIN
emptyTemplates/ai/triangle.odp
Normal file
Binary file not shown.
|
|
@ -7,6 +7,10 @@
|
|||
namespace OCA\Richdocuments\Service;
|
||||
|
||||
use OCA\Richdocuments\AppInfo\Application;
|
||||
use OCA\Richdocuments\TaskProcessing\Presentation\LayoutType;
|
||||
use OCA\Richdocuments\TaskProcessing\Presentation\Presentation;
|
||||
use OCA\Richdocuments\TaskProcessing\Presentation\Slides\TitleContentSlide;
|
||||
use OCA\Richdocuments\TaskProcessing\Presentation\Slides\TitleSlide;
|
||||
use OCA\Richdocuments\TemplateManager;
|
||||
use OCP\IConfig;
|
||||
use OCP\TaskProcessing\Exception\Exception;
|
||||
|
|
@ -20,11 +24,40 @@ use RuntimeException;
|
|||
|
||||
class SlideDeckService {
|
||||
public const PROMPT = <<<EOF
|
||||
Draft a presentation slide deck with headlines and a maximum of 5 bullet points per headline.
|
||||
Use the following JSON structure for your whole output and output only the JSON array:
|
||||
Draft a presentation with slides based on the following JSON.
|
||||
Replace the title, subtitle, and content with your own.
|
||||
If the content is an array of bullet point strings, replace them as necessary and always use at least four of them. Do not place any dot (.) or hyphen (-) before the bullet points.
|
||||
Choose one of the following three presentation styles and replace the "presentationStyle" field with your choice: security, pitch, triangle
|
||||
Security is dark and serious, pitch is light and playful, and triangle is light and geometric.
|
||||
The slide titles should not contain more than three words.
|
||||
Use the following JSON structure for your entire output.
|
||||
Output 5 or more of the JSON objects, and use each of them at least once.
|
||||
Output only the JSON array:
|
||||
|
||||
```
|
||||
[{"headline": "Headline 1", "points": ["Bullet point 1", "Bullet point 2"]}, {"headline": "Headline 2", "points": ["Bullet point 1", "Bullet point 2"]}]
|
||||
[
|
||||
{
|
||||
"layout": 0,
|
||||
"title": "Presentation title",
|
||||
"subtitle": "Presentation subtitle"
|
||||
},
|
||||
{
|
||||
"layout": 1,
|
||||
"title": "Slide title",
|
||||
"content": "Paragraph or other longer text"
|
||||
},
|
||||
{
|
||||
"layout": 1,
|
||||
"title": "Slide title",
|
||||
"content": [
|
||||
"Bullet point one",
|
||||
"Bullet point two",
|
||||
"Bullet point three",
|
||||
"Bullet point four"
|
||||
]
|
||||
},
|
||||
{ "presentationStyle": "" },
|
||||
]
|
||||
```
|
||||
|
||||
Only output the JSON array. Do not wrap it with spaces, new lines or backticks (`).
|
||||
|
|
@ -45,19 +78,21 @@ EOF;
|
|||
|
||||
$ooxml = $this->config->getAppValue(Application::APPNAME, 'doc_format', 'ooxml') === 'ooxml';
|
||||
$format = $ooxml ? 'pptx' : 'odp';
|
||||
$emptyPresentation = $this->getBlankPresentation($format);
|
||||
|
||||
try {
|
||||
$parsedStructure = $this->parseModelJSON($rawModelOutput);
|
||||
[$presentationStyle, $parsedStructure] = $this->parseModelJSON($rawModelOutput);
|
||||
} catch (\JsonException) {
|
||||
throw new RuntimeException('LLM generated faulty JSON data');
|
||||
}
|
||||
|
||||
$emptyPresentation = $this->getPresentationTemplate($presentationStyle);
|
||||
|
||||
try {
|
||||
$transformedPresentation = $this->remoteService->transformDocumentStructure(
|
||||
'presentation.' . $format,
|
||||
$emptyPresentation,
|
||||
$parsedStructure
|
||||
$parsedStructure,
|
||||
$format
|
||||
);
|
||||
|
||||
return $transformedPresentation;
|
||||
|
|
@ -81,37 +116,49 @@ EOF;
|
|||
flags: JSON_THROW_ON_ERROR
|
||||
);
|
||||
|
||||
$slideCommands = [];
|
||||
foreach ($modelJSON as $index => $slide) {
|
||||
if (count($slideCommands) > 0) {
|
||||
$slideCommands[] = [ 'JumpToSlide' => 'last' ];
|
||||
$slideCommands[] = [ 'InsertMasterSlide' => 0 ];
|
||||
} else {
|
||||
$slideCommands[] = [ 'JumpToSlide' => $index];
|
||||
$layoutTypes = array_column(LayoutType::cases(), 'value');
|
||||
$presentation = new Presentation();
|
||||
|
||||
foreach ($modelJSON as $index => $slideJSON) {
|
||||
if ($slideJSON['presentationStyle']) {
|
||||
$presentation->setStyle($slideJSON['presentationStyle']);
|
||||
continue;
|
||||
}
|
||||
|
||||
$slideCommands[] = [ 'ChangeLayoutByName' => 'AUTOLAYOUT_TITLE_CONTENT' ];
|
||||
$slideCommands[] = [ 'SetText.0' => $slide['headline'] ];
|
||||
$validLayout = array_key_exists($slideJSON['layout'], $layoutTypes);
|
||||
|
||||
$editTextObjectCommands = [
|
||||
[ 'SelectParagraph' => 0 ],
|
||||
[ 'InsertText' => implode(PHP_EOL, $slide['points']) ],
|
||||
];
|
||||
if (!$validLayout) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slideCommands[] = [ 'EditTextObject.1' => $editTextObjectCommands ];
|
||||
$slideLayout = LayoutType::from($layoutTypes[$slideJSON['layout']]);
|
||||
|
||||
$slide = match ($slideLayout) {
|
||||
LayoutType::Title => new TitleSlide($index, $slideJSON['title'], $slideJSON['subtitle']),
|
||||
|
||||
LayoutType::TitleContent => new TitleContentSlide($index, $slideJSON['title'], $slideJSON['content']),
|
||||
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (is_null($slide)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$presentation->addSlide($slide);
|
||||
}
|
||||
|
||||
return [ 'SlideCommands' => $slideCommands ];
|
||||
return [$presentation->getStyle(), $presentation->getSlideCommands()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a blank presentation file in memory
|
||||
* Creates a presentation file in memory
|
||||
*
|
||||
* @param string $format
|
||||
* @param string $name
|
||||
* @return resource
|
||||
*/
|
||||
private function getBlankPresentation(string $format) {
|
||||
$emptyPresentationContent = $this->templateManager->getEmptyFileContent($format);
|
||||
private function getPresentationTemplate(string $name = '') {
|
||||
$emptyPresentationContent = $this->templateManager->getAITemplate($name);
|
||||
$memoryStream = fopen('php://memory', 'r+');
|
||||
|
||||
if (!$memoryStream) {
|
||||
|
|
|
|||
12
lib/TaskProcessing/Presentation/ISlide.php
Normal file
12
lib/TaskProcessing/Presentation/ISlide.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Richdocuments\TaskProcessing\Presentation;
|
||||
|
||||
interface ISlide {
|
||||
public function getPosition(): int;
|
||||
public function getSlideCommands(): array;
|
||||
}
|
||||
13
lib/TaskProcessing/Presentation/LayoutType.php
Normal file
13
lib/TaskProcessing/Presentation/LayoutType.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Richdocuments\TaskProcessing\Presentation;
|
||||
|
||||
enum LayoutType: string {
|
||||
case Title = 'AUTOLAYOUT_TITLE';
|
||||
case TitleContent = 'AUTOLAYOUT_TITLE_CONTENT';
|
||||
case Title2Content = 'AUTOLAYOUT_TITLE_2CONTENT';
|
||||
}
|
||||
56
lib/TaskProcessing/Presentation/Presentation.php
Normal file
56
lib/TaskProcessing/Presentation/Presentation.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Richdocuments\TaskProcessing\Presentation;
|
||||
|
||||
class Presentation {
|
||||
/** @var ISlide[] $slides */
|
||||
private $slides = [];
|
||||
|
||||
/** @var string $style */
|
||||
private $style = 'security';
|
||||
|
||||
public function __construct() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ISlide $slide Slide to be inserted into the presentation
|
||||
*/
|
||||
public function addSlide(ISlide $slide): void {
|
||||
$this->slides[] = $slide;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ISlide[] Array of slides in the presentation
|
||||
*/
|
||||
public function getSlides(): array {
|
||||
return $this->slides;
|
||||
}
|
||||
|
||||
public function setStyle(string $style): void {
|
||||
$this->style = $style;
|
||||
}
|
||||
|
||||
public function getStyle(): string {
|
||||
return $this->style;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array Slide commands to be passed to an external API
|
||||
*/
|
||||
public function getSlideCommands(): array {
|
||||
$slideCommands = array_map(
|
||||
function (ISlide $slide) {
|
||||
return $slide->getSlideCommands();
|
||||
},
|
||||
$this->getSlides(),
|
||||
);
|
||||
|
||||
$slideCommands = array_merge([], ...$slideCommands);
|
||||
|
||||
return [ 'SlideCommands' => $slideCommands ];
|
||||
}
|
||||
}
|
||||
75
lib/TaskProcessing/Presentation/Slides/TitleContentSlide.php
Normal file
75
lib/TaskProcessing/Presentation/Slides/TitleContentSlide.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Richdocuments\TaskProcessing\Presentation\Slides;
|
||||
|
||||
use OCA\Richdocuments\TaskProcessing\Presentation\ISlide;
|
||||
|
||||
class TitleContentSlide implements ISlide {
|
||||
private int $position;
|
||||
private string $title;
|
||||
private string|array $content;
|
||||
|
||||
public function __construct(
|
||||
int $position,
|
||||
string $title,
|
||||
string|array $content) {
|
||||
$this->position = $position;
|
||||
$this->title = $title;
|
||||
$this->content = $content;
|
||||
}
|
||||
|
||||
public function getTitle(): string {
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function getContent(): string|array {
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
public function getPosition(): int {
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function getSlideCommands(): array {
|
||||
$slideCommands = [];
|
||||
|
||||
if ($this->getPosition() > 1) {
|
||||
$slideCommands[] = [ 'DuplicateSlide' => $this->getPosition() - 1 ];
|
||||
}
|
||||
|
||||
$slideCommands[] = [ 'JumpToSlide' => $this->getPosition() ];
|
||||
|
||||
$slideCommands[] = [
|
||||
'EditTextObject.0' => [
|
||||
'SelectParagraph' => 0,
|
||||
'InsertText' => $this->getTitle(),
|
||||
]
|
||||
];
|
||||
|
||||
if (is_array($this->getContent())) {
|
||||
$slideCommands[] = [
|
||||
'EditTextObject.1' => [
|
||||
'SelectText' => [],
|
||||
'UnoCommand' => '.uno:Cut',
|
||||
'InsertText' => implode(PHP_EOL, array_map(function ($bulletPoint) {
|
||||
return '• ' . $bulletPoint;
|
||||
}, $this->getContent())),
|
||||
]
|
||||
];
|
||||
} else {
|
||||
$slideCommands[] = [
|
||||
'EditTextObject.1' => [
|
||||
'SelectText' => [],
|
||||
'UnoCommand' => '.uno:Cut',
|
||||
'InsertText' => $this->getContent(),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
return $slideCommands;
|
||||
}
|
||||
}
|
||||
56
lib/TaskProcessing/Presentation/Slides/TitleSlide.php
Normal file
56
lib/TaskProcessing/Presentation/Slides/TitleSlide.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Richdocuments\TaskProcessing\Presentation\Slides;
|
||||
|
||||
use OCA\Richdocuments\TaskProcessing\Presentation\ISlide;
|
||||
|
||||
class TitleSlide implements ISlide {
|
||||
private int $position;
|
||||
private string $title;
|
||||
private string $subtitle;
|
||||
|
||||
public function __construct(
|
||||
int $position,
|
||||
string $title,
|
||||
string $subtitle) {
|
||||
$this->position = $position;
|
||||
$this->title = $title;
|
||||
$this->subtitle = $subtitle;
|
||||
}
|
||||
|
||||
public function getTitle(): string {
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function getSubtitle(): string {
|
||||
return $this->subtitle;
|
||||
}
|
||||
|
||||
public function getPosition(): int {
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function getSlideCommands(): array {
|
||||
$slideCommands = [];
|
||||
|
||||
$slideCommands[] = [
|
||||
'EditTextObject.0' => [
|
||||
'SelectParagraph' => 0,
|
||||
'InsertText' => $this->getTitle(),
|
||||
]
|
||||
];
|
||||
|
||||
$slideCommands[] = [
|
||||
'EditTextObject.1' => [
|
||||
'SelectParagraph' => 0,
|
||||
'InsertText' => $this->getSubtitle(),
|
||||
]
|
||||
];
|
||||
|
||||
return $slideCommands;
|
||||
}
|
||||
}
|
||||
|
|
@ -80,11 +80,28 @@ class SlideDeckGenerationProvider implements ISynchronousProvider {
|
|||
throw new \RuntimeException('Invalid input, expected "text" key with string value');
|
||||
}
|
||||
|
||||
$response = $this->slideDeckService->generateSlideDeck(
|
||||
$userId,
|
||||
$input['text'],
|
||||
);
|
||||
$response = $this->withRetry(function () use ($userId, $input) {
|
||||
return $this->slideDeckService->generateSlideDeck(
|
||||
$userId,
|
||||
$input['text'],
|
||||
);
|
||||
});
|
||||
|
||||
return ['slide_deck' => $response];
|
||||
}
|
||||
|
||||
private function withRetry(callable $action, $maxAttempts = 2) {
|
||||
$attempt = 0;
|
||||
|
||||
while ($attempt < $maxAttempts) {
|
||||
try {
|
||||
$attempt += 1;
|
||||
return $action();
|
||||
} catch (\Exception $e) {
|
||||
if ($attempt === $maxAttempts) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -471,6 +471,21 @@ class TemplateManager {
|
|||
return true;
|
||||
}
|
||||
|
||||
public function getAITemplate(?string $templateName = 'security'): string {
|
||||
$emptyAITemplates = __DIR__ . '/../emptyTemplates/ai/';
|
||||
$fullTemplatePath = $emptyAITemplates . $templateName . '.odp';
|
||||
|
||||
if (file_exists($fullTemplatePath)) {
|
||||
$emptyFileContent = file_get_contents($fullTemplatePath);
|
||||
|
||||
if ($emptyFileContent !== false) {
|
||||
return $emptyFileContent;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return default content for empty files of a given filename by file extension
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Tests\Richdocuments;
|
||||
|
||||
use OCA\Richdocuments\TaskProcessing\Presentation\Slides\TitleContentSlide;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class TitleContentSlideTest extends TestCase {
|
||||
public function setUp(): void {
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
public function testCreateWithStringContent(): void {
|
||||
$slide = new TitleContentSlide(0, 'Title', 'Content');
|
||||
|
||||
$this->assertInstanceOf(TitleContentSlide::class, $slide);
|
||||
|
||||
$this->assertEquals($slide->getPosition(), 0);
|
||||
$this->assertEquals($slide->getTitle(), 'Title');
|
||||
|
||||
$this->assertIsString($slide->getContent());
|
||||
$this->assertEquals($slide->getContent(), 'Content');
|
||||
}
|
||||
|
||||
public function testCreateWithArrayContent(): void {
|
||||
$slide = new TitleContentSlide(0, 'Title', ['Content', 'Content']);
|
||||
|
||||
$this->assertInstanceOf(TitleContentSlide::class, $slide);
|
||||
|
||||
$this->assertEquals($slide->getPosition(), 0);
|
||||
$this->assertEquals($slide->getTitle(), 'Title');
|
||||
|
||||
$this->assertIsArray($slide->getContent());
|
||||
$this->assertEquals($slide->getContent(), ['Content', 'Content']);
|
||||
}
|
||||
|
||||
public function testSlideCommandsWithStringContent(): void {
|
||||
$slide = new TitleContentSlide(0, 'Title', 'Content');
|
||||
|
||||
$expectedSlideCommands = [
|
||||
[ 'JumpToSlide' => 0 ],
|
||||
|
||||
[
|
||||
'EditTextObject.0' => [
|
||||
'SelectParagraph' => 0,
|
||||
'InsertText' => 'Title',
|
||||
]
|
||||
],
|
||||
[
|
||||
'EditTextObject.1' => [
|
||||
'SelectParagraph' => 0,
|
||||
'InsertText' => 'Content',
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$this->assertJsonStringEqualsJsonString(
|
||||
json_encode($expectedSlideCommands),
|
||||
json_encode($slide->getSlideCommands()),
|
||||
);
|
||||
}
|
||||
|
||||
public function testSlideCommandsWithArrayContent(): void {
|
||||
$slide = new TitleContentSlide(0, 'Title', ['Content', 'Content']);
|
||||
|
||||
$expectedSlideCommands = [
|
||||
// Jump to slide at $this->getPosition()
|
||||
[ 'JumpToSlide' => 0 ],
|
||||
|
||||
[
|
||||
'EditTextObject.0' => [
|
||||
'SelectParagraph' => 0,
|
||||
'InsertText' => 'Title',
|
||||
]
|
||||
],
|
||||
[
|
||||
'EditTextObject.1' => [
|
||||
'SelectParagraph' => 0,
|
||||
'InsertText' => 'Content' . PHP_EOL . 'Content',
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$this->assertJsonStringEqualsJsonString(
|
||||
json_encode($expectedSlideCommands),
|
||||
json_encode($slide->getSlideCommands()),
|
||||
);
|
||||
}
|
||||
|
||||
public function testSlideCommandsWithPosition(): void {
|
||||
$slide = new TitleContentSlide(2, 'Title', 'Content');
|
||||
|
||||
$expectedSlideCommands = [
|
||||
// Duplicates slide at index $this->getPosition() - 1
|
||||
[ 'DuplicateSlide' => 1 ],
|
||||
|
||||
// Jump to slide at $this->getPosition()
|
||||
[ 'JumpToSlide' => 2 ],
|
||||
|
||||
[
|
||||
'EditTextObject.0' => [
|
||||
'SelectParagraph' => 0,
|
||||
'InsertText' => 'Title',
|
||||
]
|
||||
],
|
||||
[
|
||||
'EditTextObject.1' => [
|
||||
'SelectParagraph' => 0,
|
||||
'InsertText' => 'Content',
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$this->assertJsonStringEqualsJsonString(
|
||||
json_encode($expectedSlideCommands),
|
||||
json_encode($slide->getSlideCommands()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Tests\Richdocuments;
|
||||
|
||||
use OCA\Richdocuments\TaskProcessing\Presentation\Slides\TitleSlide;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class TitleSlideTest extends TestCase {
|
||||
public function setUp(): void {
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
public function testCreateTitleSlide(): void {
|
||||
$slide = new TitleSlide(0, 'Title', 'Subtitle');
|
||||
|
||||
$this->assertInstanceOf(TitleSlide::class, $slide);
|
||||
|
||||
$this->assertEquals($slide->getPosition(), 0);
|
||||
$this->assertEquals($slide->getTitle(), 'Title');
|
||||
$this->assertEquals($slide->getSubtitle(), 'Subtitle');
|
||||
}
|
||||
|
||||
public function testSlideCommands(): void {
|
||||
$slide = new TitleSlide(0, 'Title', 'Subtitle');
|
||||
|
||||
$expectedSlideCommands = [
|
||||
[
|
||||
'EditTextObject.0' => [
|
||||
'SelectParagraph' => 0,
|
||||
'InsertText' => 'Title',
|
||||
],
|
||||
],
|
||||
[
|
||||
'EditTextObject.1' => [
|
||||
'SelectParagraph' => 0,
|
||||
'InsertText' => 'Subtitle',
|
||||
]
|
||||
],
|
||||
];
|
||||
|
||||
$this->assertJsonStringEqualsJsonString(
|
||||
json_encode($expectedSlideCommands),
|
||||
json_encode($slide->getSlideCommands()),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue