diff --git a/Makefile b/Makefile index 1dfe643f5..9e3ea2436 100644 --- a/Makefile +++ b/Makefile @@ -456,6 +456,24 @@ export-elp-epub3: export-elp-ims: @$(MAKE) export-elp FORMAT=ims INPUT="$(INPUT)" OUTPUT="$(OUTPUT)" DEBUG="$(DEBUG)" BASE_URL="$(BASE_URL)" +export-elp-h5p: +ifndef INPUT + $(error INPUT is required. Use INPUT=/absolute/path/to/file.elp) +endif +ifndef OUTPUT + $(error OUTPUT is required. Use OUTPUT=/absolute/path/to/output.h5p) +endif + $(eval EXPANDED_INPUT := $(call EXPAND_PATH,$(INPUT))) + @if [ ! -f "$(EXPANDED_INPUT)" ]; then \ + echo "❌ INPUT file does not exist: $(EXPANDED_INPUT)"; \ + exit 1; \ + fi + @mkdir -p $(dir $(OUTPUT)) + $(eval TMP_DIR := $(OUTPUT).tmpdir) + @$(MAKE) export-elp FORMAT=h5p INPUT="$(EXPANDED_INPUT)" OUTPUT="$(TMP_DIR)" DEBUG="$(DEBUG)" BASE_URL="$(BASE_URL)" + @mv "$(TMP_DIR)"/*.h5p "$(OUTPUT)" + @rm -rf "$(TMP_DIR)" + # Install nativephp/php-bin package temporarily without modifying composer.json install-php-bin: @@ -558,6 +576,7 @@ help: @echo " export-elp-scorm2004 - Export .elp to SCORM 2004 format (alias for FORMAT=scorm2004)" @echo " export-elp-ims - Export .elp to IMS format (alias for FORMAT=ims)" @echo " export-elp-epub3 - Export .elp to EPUB 3 format (alias for FORMAT=epub3)" + @echo " export-elp-h5p - Export .elp to H5P (experimental) format (alias for FORMAT=h5p)" @echo " export-elp-elp - Re-export .elp file (alias for FORMAT=elp)" @echo "" @echo "Data:" diff --git a/README.md b/README.md index 1295e23c5..9d47c88b8 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ This version is built with modern technologies (PHP 8, Symfony 7) and provides a * Creation and edition of interactive educational content * Multiple iDevices (interactive elements) * Multilingual support -* Exportation to various formats +* Exportation to multiple formats (SCORM, IMS, EPUB, HTML and H5P) * Moodle integration * [RESTful API](./doc/development/rest-api.md) Self-documented with Swagger * Real-time collaborative features powered by [Mercure](https://mercure.rocks/) diff --git a/doc/development/environment.md b/doc/development/environment.md index c704ce63d..6d536f593 100644 --- a/doc/development/environment.md +++ b/doc/development/environment.md @@ -136,10 +136,20 @@ The project provides a Makefile to simplify common tasks: | `make test` | Run unit tests | | `make lint` | Check PHP code style | | `make fix` | Automatically fix code style issues | -| `make update` | Update Composer dependencies | -| `make translations` | Update translation files | - -### Installing `make` +| `make update` | Update Composer dependencies | +| `make translations` | Update translation files | + +### Exporting Content + +Use the Makefile to generate packages from `.elp` projects. For example, to create an experimental H5P package: + +```bash +make export-elp-h5p INPUT=/path/project.elp OUTPUT=/path/project.h5p +``` + +Other formats can be exported by replacing `h5p` with `html5`, `scorm12`, `scorm2004`, `ims`, or `epub3`. + +### Installing `make` On Linux, `make` is usually pre-installed. On Windows, install it with: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 49d84f281..5b17cb07e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -69,6 +69,7 @@ + diff --git a/public/app/workarea/menus/navbar/items/navbarFile.js b/public/app/workarea/menus/navbar/items/navbarFile.js index 47a5777c5..902010489 100644 --- a/public/app/workarea/menus/navbar/items/navbarFile.js +++ b/public/app/workarea/menus/navbar/items/navbarFile.js @@ -81,6 +81,9 @@ export default class NavbarFile { this.exportEPUB3AsButton = this.menu.navbar.querySelector( '#navbar-button-exportas-epub3' ); + this.exportH5PButton = this.menu.navbar.querySelector( + '#navbar-button-export-h5p' + ); this.exportXmlPropertiesButton = this.menu.navbar.querySelector( '#navbar-button-export-xml-properties' ); @@ -127,6 +130,7 @@ export default class NavbarFile { this.setExportIMSEvent(); this.setExportIMSAsEvent(); this.setExportEPUB3Event(); + this.setExportH5PEvent(); this.setExportEPUB3AsEvent(); this.setExportXmlPropertiesEvent(); this.setExportXmlPropertiesAsEvent(); @@ -439,6 +443,14 @@ export default class NavbarFile { }); } + setExportH5PEvent() { + if (!this.exportH5PButton) return; + this.exportH5PButton.addEventListener('click', () => { + if (eXeLearning.app.project.checkOpenIdevice()) return; + this.exportH5PEvent(); + }); + } + setExportEPUB3AsEvent() { if (!this.exportEPUB3AsButton) return; this.exportEPUB3AsButton.addEventListener('click', () => { @@ -1207,7 +1219,6 @@ export default class NavbarFile { } /** -<<<<<<< HEAD * Export Website to folder (unzipped) — offline Electron only */ async exportHTML5FolderAsEvent() { @@ -1285,8 +1296,6 @@ export default class NavbarFile { } /** -======= ->>>>>>> c0ba7aea408904076081df962baf800d79424a91 * Export the ode as HTML5 and download it * */ @@ -1782,6 +1791,99 @@ export default class NavbarFile { eXeLearning.app.interface.connectionTime.loadLasUpdatedInInterface(); } + async exportH5PEvent() { + let toastData = { + title: _('Export'), + body: _('Generating export files...'), + icon: 'downloading', + }; + let toast = eXeLearning.app.toasts.createToast(toastData); + let odeSessionId = eXeLearning.app.project.odeSession; + let response = await eXeLearning.app.api.getOdeExportDownload( + odeSessionId, + 'h5p' + ); + if (response['responseMessage'] == 'OK') { + this.downloadLink( + response['urlZipFile'], + response['exportProjectName'] + ); + toast.toastBody.innerHTML = _('The project has been exported.'); + } else { + toast.toastBody.innerHTML = _( + 'An error occurred while exporting the project.' + ); + toast.toastBody.classList.add('error'); + eXeLearning.app.modals.alert.show({ + title: _('Error'), + body: response['responseMessage'] + ? response['responseMessage'] + : _('Unknown error.'), + contentId: 'error', + }); + } + + setTimeout(() => { + toast.remove(); + }, 1000); + + eXeLearning.app.interface.connectionTime.loadLasUpdatedInInterface(); + } + + /** + * Export ePub3 (Save As...) + */ + async exportEPUB3AsEvent() { + let toastData = { + title: _('Export'), + body: _('Generating export files...'), + icon: 'downloading', + }; + let toast = eXeLearning.app.toasts.createToast(toastData); + let odeSessionId = eXeLearning.app.project.odeSession; + let response = await eXeLearning.app.api.getOdeExportDownload( + odeSessionId, + 'epub3' + ); + if (response['responseMessage'] == 'OK') { + const url = response['urlZipFile']; + const suggested = this.normalizeSuggestedName( + response['exportProjectName'], + 'export-epub3' + ); + const keyBase = window.__currentProjectId || 'default'; + if ( + window.electronAPI && + typeof window.electronAPI.saveAs === 'function' + ) { + await window.electronAPI.saveAs( + url, + `${keyBase}:export-epub3`, + suggested + ); + } else { + this.downloadLink(url, suggested); + } + toast.toastBody.innerHTML = _('The project has been exported.'); + } else { + toast.toastBody.innerHTML = _( + 'An error occurred while exporting the project.' + ); + toast.toastBody.classList.add('error'); + eXeLearning.app.modals.alert.show({ + title: _('Error'), + body: response['responseMessage'] + ? response['responseMessage'] + : _('Unknown error.'), + contentId: 'error', + }); + } + setTimeout(() => { + toast.remove(); + }, 1000); + eXeLearning.app.interface.connectionTime.loadLasUpdatedInInterface(); + } + /** * Export ePub3 (Save As...) */ diff --git a/src/Command/net/exelearning/Command/ElpExportCommand.php b/src/Command/net/exelearning/Command/ElpExportCommand.php index 1ab18bafd..363c01dba 100644 --- a/src/Command/net/exelearning/Command/ElpExportCommand.php +++ b/src/Command/net/exelearning/Command/ElpExportCommand.php @@ -45,7 +45,7 @@ protected function configure(): void $this ->addArgument('input', InputArgument::REQUIRED, 'Input ELP file path (use "-" for stdin)') ->addArgument('output', InputArgument::REQUIRED, 'Output directory') - ->addArgument('format', InputArgument::OPTIONAL, 'Export format (elp, html5, html5-sp, scorm12, scorm2004, ims, epub3)', $this->defaultFormat) + ->addArgument('format', InputArgument::OPTIONAL, 'Export format (elp, html5, html5-sp, scorm12, scorm2004, ims, epub3, h5p)', $this->defaultFormat) ->addOption('debug', 'd', InputOption::VALUE_NONE, 'Enable debug mode') ->addOption('base-url', 'b', InputOption::VALUE_OPTIONAL, 'Base URL for links', false); } @@ -67,6 +67,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int Constants::EXPORT_TYPE_SCORM2004, Constants::EXPORT_TYPE_IMS, Constants::EXPORT_TYPE_EPUB3, + Constants::EXPORT_TYPE_H5P, ]; if (!in_array($format, $validFormats, true)) { diff --git a/src/Command/net/exelearning/Command/ElpExportH5pCommand.php b/src/Command/net/exelearning/Command/ElpExportH5pCommand.php new file mode 100644 index 000000000..abeb0468e --- /dev/null +++ b/src/Command/net/exelearning/Command/ElpExportH5pCommand.php @@ -0,0 +1,15 @@ +publish( diff --git a/src/Doctrine/Middleware/SqlitePragmaMiddleware.php b/src/Doctrine/Middleware/SqlitePragmaMiddleware.php index e267172e3..71960a153 100644 --- a/src/Doctrine/Middleware/SqlitePragmaMiddleware.php +++ b/src/Doctrine/Middleware/SqlitePragmaMiddleware.php @@ -28,18 +28,21 @@ public function connect( return $connection; } - // WAL keeps readers and writers from blocking each other. - $connection->exec('PRAGMA journal_mode = WAL'); - // NORMAL sync trims redundant fsyncs without losing crash safety on checkpoints. - $connection->exec('PRAGMA synchronous = NORMAL'); - // Retries for ~5s curb transient "database is locked" errors when writers race. - $connection->exec('PRAGMA busy_timeout = 5000'); - // Doctrine disables foreign keys by default for SQLite; force them back on. - $connection->exec('PRAGMA foreign_keys = ON'); - // Temp tables and sorting spill to memory to avoid disk-backed temp files. - $connection->exec('PRAGMA temp_store = MEMORY'); - // Negative cache size sets ~4MB memory caching for hot pages (no disk persistence). - $connection->exec('PRAGMA cache_size = -4000'); + // Skip WAL and disk-related PRAGMAs for pure in-memory DBs. + if (!$this->isMemory($params)) { + // WAL keeps readers and writers from blocking each other. + $connection->exec('PRAGMA journal_mode = WAL'); + // NORMAL sync trims redundant fsyncs without losing crash safety on checkpoints. + $connection->exec('PRAGMA synchronous = NORMAL'); + // Retries for ~5s curb transient "database is locked" errors when writers race. + $connection->exec('PRAGMA busy_timeout = 5000'); + // Doctrine disables foreign keys by default for SQLite; force them back on. + $connection->exec('PRAGMA foreign_keys = ON'); + // Temp tables and sorting spill to memory to avoid disk-backed temp files. + $connection->exec('PRAGMA temp_store = MEMORY'); + // Negative cache size sets ~4MB memory caching for hot pages (no disk persistence). + $connection->exec('PRAGMA cache_size = -4000'); + } return $connection; } @@ -71,6 +74,28 @@ private function isSqlite(array $params): bool return false; } + + /** + * Detects in-memory SQLite (":memory:" or memory param). + * + * @param array $params + */ + private function isMemory(array $params): bool + { + if (!empty($params['memory'])) { + return true; + } + $url = $params['url'] ?? null; + if (\is_string($url) && \str_contains($url, ':memory:')) { + return true; + } + $path = $params['path'] ?? null; + if (\is_string($path) && ':memory:' === $path) { + return true; + } + + return false; + } }; } } diff --git a/src/Service/net/exelearning/Service/Api/OdeExportService.php b/src/Service/net/exelearning/Service/Api/OdeExportService.php index 73ab3112d..d5b19a6d7 100644 --- a/src/Service/net/exelearning/Service/Api/OdeExportService.php +++ b/src/Service/net/exelearning/Service/Api/OdeExportService.php @@ -15,11 +15,13 @@ use App\Helper\net\exelearning\Helper\UserHelper; use App\Properties; use App\Service\net\exelearning\Service\Export\ExportEPUB3Service; +use App\Service\net\exelearning\Service\Export\ExportH5PService; use App\Service\net\exelearning\Service\Export\ExportHTML5Service; use App\Service\net\exelearning\Service\Export\ExportHTML5SPService; use App\Service\net\exelearning\Service\Export\ExportIMSService; use App\Service\net\exelearning\Service\Export\ExportSCORM12Service; use App\Service\net\exelearning\Service\Export\ExportSCORM2004Service; +use App\Settings; use App\Util\net\exelearning\Util\Commoni18nUtil; use App\Util\net\exelearning\Util\ExportXmlUtil; use App\Util\net\exelearning\Util\FilePermissionsUtil; @@ -52,6 +54,7 @@ class OdeExportService implements OdeExportServiceInterface private ExportIMSService $exportIMSService; private ExportEPUB3Service $exportEPUB3Service; private SluggerInterface $slugger; + private ExportH5PService $exportH5PService; public function __construct( EntityManagerInterface $entityManager, @@ -70,6 +73,7 @@ public function __construct( ExportSCORM2004Service $exportSCORM2004Service, ExportIMSService $exportIMSService, ExportEPUB3Service $exportEPUB3Service, + ExportH5PService $exportH5PService, SluggerInterface $slugger, ) { $this->entityManager = $entityManager; @@ -88,6 +92,7 @@ public function __construct( $this->exportSCORM2004Service = $exportSCORM2004Service; $this->exportIMSService = $exportIMSService; $this->exportEPUB3Service = $exportEPUB3Service; + $this->exportH5PService = $exportH5PService; $this->slugger = $slugger; } @@ -277,14 +282,11 @@ public function export( return $response; } - // Export dir path - // $exportDirPath = $this->fileHelper->getOdeSessionUserTmpExportDir($odeSessionId, $dbUser); + // Export dir path (already includes $tempPath if provided) $exportDirPath = $this->fileHelper->getOdeSessionUserTmpExportDir($odeSessionId, $dbUser, $tempPath); - $exportDirPath = $exportDirPath.$tempPath; - // Get url to export dir - $urlExportDir = UrlUtil::getOdeSessionExportUrl($odeSessionId, $dbUser); - $urlExportDir = $urlExportDir.$tempPath; + // Get url to export dir (append tempPath once for preview) + $urlExportDir = UrlUtil::getOdeSessionExportUrl($odeSessionId, $dbUser).$tempPath; // Index filename $indexFileName = self::generateIndexFileName(); @@ -301,6 +303,10 @@ public function export( case Constants::EXPORT_TYPE_EPUB3: $ext = Constants::FILE_EXTENSION_EPUB; break; + case Constants::EXPORT_TYPE_H5P: + $ext = Constants::FILE_EXTENSION_H5P; + $typeSuffix = Constants::SUFFIX_TYPE_H5P; + break; case Constants::EXPORT_TYPE_HTML5: $ext = Constants::FILE_EXTENSION_ZIP; $typeSuffix = Constants::SUFFIX_TYPE_HTML5; @@ -331,6 +337,8 @@ public function export( $response['urlZipFile'] = $urlExportDir.$exportFileName; // Add zip file name to response $response['zipFileName'] = $exportFileName; + // Provide export file name for clients + $response['exportProjectName'] = $exportFileName; // Stores the ode permanently $this->odeService->moveElpFileToPerm($saveOdeResultParameters, $dbUser, $isManualSave); @@ -540,6 +548,51 @@ public function generateExportStructure( $resourcesPrefix = ''; } + // H5P export: generate a minimal H5P package and return early + if (Constants::EXPORT_TYPE_H5P === $exportType) { + // Start with a clean directory + FileUtil::removeDirContent($exportParentDirPath); + + // Compute pages data without creating extra dirs + $odeNavStructureSyncsSorted = self::getOdeNavStructureSyncsSorted($odeNavStructureSyncs); + $pagesFileData = self::getPagesData( + $exportDirPath, + $odeNavStructureSyncsSorted, + $userPreferencesDtos, + '', + $exportType + ); + + try { + $this->exportH5PService->generateExportFiles( + $user, + $odeSessionId, + $odeNavStructureSyncsSorted, + $pagesFileData, + $odeProperties, + [], + [], + [], + [], + [], + $userPreferencesDtos, + $theme, + '', + '', + false, + $this->translator + ); + } catch (\Throwable $e) { + $response['responseMessage'] = $this->translator->trans('Export generation error'); + + return $response; + } + + $response['responseMessage'] = 'OK'; + + return $response; + } + // Check if the ELP should be added to the export // TODO: The next if statement will be a bit difference when exe has a new preference // related to add ELP to an export diff --git a/src/Service/net/exelearning/Service/Export/ExportH5PService.php b/src/Service/net/exelearning/Service/Export/ExportH5PService.php new file mode 100644 index 000000000..6405822c2 --- /dev/null +++ b/src/Service/net/exelearning/Service/Export/ExportH5PService.php @@ -0,0 +1,878 @@ +exportType = Constants::EXPORT_TYPE_H5P; + $this->fileHelper = $fileHelper; + $this->currentOdeUsersService = $currentOdeUsersService; + $this->translator = $translator; + } + + public function generateExportFiles( + $user, + $odeSessionId, + $odeNavStructureSyncs, + $pagesFileData, + $odeProperties, + $libsResourcesPath, + $odeComponentsSyncCloneArray, + $idevicesMapping, + $idevicesByPage, + $idevicesTypesData, + $userPreferencesDtos, + $theme, + $elpFileName, + $resourcesPrefix, + $isPreview, + $translator, + ) { + $exportDirPath = $this->fileHelper->getOdeSessionUserTmpExportDir($odeSessionId, $user); + $contentDir = $exportDirPath.'content'.DIRECTORY_SEPARATOR; + if (!is_dir($contentDir)) { + mkdir($contentDir, 0775, true); + } + + $projectTitle = isset($odeProperties['pp_title']) + ? trim(strip_tags($odeProperties['pp_title']->getValue())) + : 'eXeLearning project'; + $language = isset($odeProperties['pp_lang']) + ? substr($odeProperties['pp_lang']->getValue(), 0, 2) + : 'en'; + + // Choose layout (env override for tests) + $layout = getenv('EXE_H5P_LAYOUT') ?: 'column'; + if ('course-presentation' === $layout) { + return $this->generateCoursePresentation( + $user, + $odeSessionId, + $odeNavStructureSyncs, + $pagesFileData, + $odeProperties, + $contentDir, + $exportDirPath + ); + } + + // Build Column content + // NOTE: Some H5P platforms/configurations do not allow rich media + // (e.g., ,