diff --git a/.github/workflows/docs-and-repos.yml b/.github/workflows/docs-and-repos.yml index d50c61619..f93c046b6 100644 --- a/.github/workflows/docs-and-repos.yml +++ b/.github/workflows/docs-and-repos.yml @@ -13,9 +13,7 @@ on: workflow_dispatch: # Allows manually triggering permissions: - contents: read - pages: write - id-token: write + contents: write jobs: build-and-deploy: @@ -27,14 +25,12 @@ jobs: # ---- Build MkDocs docs ---- - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install MkDocs - run: | - python -m pip install --upgrade pip - pip install mkdocs mkdocs-material + run: pip install --upgrade pip mkdocs mkdocs-material - name: Build docs run: | @@ -275,15 +271,14 @@ jobs: [ -d site/rpm ] && generate_index "site/rpm" "rpm" || true # ---- Deploy to GitHub Pages ---- - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v4 - with: - path: site - - name: Deploy to GitHub Pages if: github.repository == 'exelearning/exelearning' - id: deployment - uses: actions/deploy-pages@v4 + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: site + branch: gh-pages + clean-exclude: pr-preview + force: false publish-chocolatey: if: github.event_name == 'release' && github.event.action == 'released' diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml new file mode 100644 index 000000000..29565509f --- /dev/null +++ b/.github/workflows/pr-preview.yml @@ -0,0 +1,53 @@ +name: Deploy PR Preview + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - closed + +concurrency: preview-${{ github.ref }} + +permissions: + contents: write + pull-requests: write + +jobs: + deploy-preview: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Bun + if: github.event.action != 'closed' + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + if: github.event.action != 'closed' + run: bun install + + - name: Build static distribution + if: github.event.action != 'closed' + run: bun run build:static + + - name: Deploy preview + id: deploy + uses: rossjrw/pr-preview-action@v1 + with: + source-dir: ./dist/static/ + preview-branch: gh-pages + umbrella-dir: pr-preview + action: auto + qr-code: true + + - name: Add preview URL to summary + if: github.event.action != 'closed' + run: | + echo "## 🚀 PR Preview Deployed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Preview URL:** ${{ steps.deploy.outputs.deployment-url }}" >> $GITHUB_STEP_SUMMARY diff --git a/Makefile b/Makefile index a14f90f1b..58960fdbc 100644 --- a/Makefile +++ b/Makefile @@ -156,12 +156,33 @@ else bun run dev:local endif -# Start full app: Elysia backend + Electron +# Start full app: Static files + Electron (no server needed) .PHONY: run-app run-app: check-bun deps css bundle - @echo "Launching eXeLearning App (Electron + Elysia)..." - @bun run build:standalone - @bun run dev:app + @echo "Building static files..." + @bun scripts/build-static-bundle.ts + @echo "Launching eXeLearning App (Electron)..." + @bun run electron + +# Build static distribution (PWA mode, no server required) +# Usage: make build-static +.PHONY: build-static +build-static: check-bun deps css bundle + @echo "Building static distribution..." + @bun run build:static + @echo "Static distribution built at dist/static/" + +# Build static distribution and serve it +# Usage: make up-static [PORT=8080] +.PHONY: up-static +up-static: build-static + @echo "" + @echo "============================================================" + @echo " Serving static distribution at http://localhost:$${PORT:-8080}" + @echo " Press Ctrl+C to stop" + @echo "============================================================" + @echo "" + @bunx serve dist/static -p $${PORT:-8080} # ============================================================================= @@ -532,6 +553,13 @@ test-e2e-ui: check-env ## Run Playwright E2E tests with UI test-e2e-firefox: check-env ## Run Playwright E2E tests with Firefox bunx playwright test --project=firefox +.PHONY: test-e2e-static +test-e2e-static: check-env ## Run Playwright E2E tests with static bundle (no server) + PLAYWRIGHT_PROJECT=chromium-static bunx playwright test --project=chromium-static + +.PHONY: test-e2e-all +test-e2e-all: test-e2e test-e2e-static ## Run E2E tests for both server and static modes + # ============================================================================= # DATABASE-SPECIFIC E2E TESTS @@ -807,7 +835,10 @@ help: @echo "Local:" @echo " make up-local Start locally (web only, dev mode)" @echo " make up-local APP_ENV=prod Start locally (web only, prod mode)" - @echo " make run-app Start Electron + backend (desktop app)" + @echo " make build-static Build static distribution (PWA mode)" + @echo " make up-static Build and serve static distribution (PWA mode)" + @echo " make up-static PORT=3000 Same, but on custom port" + @echo " make run-app Start Electron app (static mode, no server)" @echo " make bundle Build all assets (TS + CSS + JS bundle)" @echo " make deps Install dependencies" @echo "" @@ -839,6 +870,8 @@ help: @echo " make test-e2e Run Playwright E2E tests (Chromium)" @echo " make test-e2e-chromium Run E2E tests with Chromium" @echo " make test-e2e-firefox Run E2E tests with Firefox" + @echo " make test-e2e-static Run E2E tests with static bundle (no server)" + @echo " make test-e2e-all Run E2E tests for both server and static modes" @echo "" @echo "Legacy (Core2 Duo / No Bun):" @echo " make up-legacy Start legacy server with Node.js (Docker)" diff --git a/assets/styles/main.scss b/assets/styles/main.scss index 96ecc4774..e5bb85a85 100644 --- a/assets/styles/main.scss +++ b/assets/styles/main.scss @@ -39,9 +39,26 @@ body[mode="advanced"] .exe-simplified { display: none !important; } -/* eXe Mode */ -body[installation-type="offline"] .exe-online, -body[installation-type="online"] .exe-offline { +/* eXe Mode - installation type visibility */ +/* Online mode: hide offline and electron elements, show online elements */ +body[installation-type="online"] .exe-offline, +body[installation-type="online"] .exe-electron { + display: none !important; +} + +/* Static mode: hide online and electron elements, show offline elements (exe logo) */ +body[installation-type="static"] .exe-online, +body[installation-type="static"] .exe-electron { + display: none !important; +} + +/* Electron mode: hide online elements, show offline and electron elements */ +body[installation-type="electron"] .exe-online { + display: none !important; +} + +/* Legacy support: "offline" value maps to electron behavior */ +body[installation-type="offline"] .exe-online { display: none !important; } diff --git a/doc/architecture.md b/doc/architecture.md index 1a573d437..353c62650 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -532,6 +532,99 @@ ws.on('open', async () => { }); ``` +## 13. Theme Architecture + +### 13.1 Theme Types + +eXeLearning supports three types of themes: + +| Type | Source | Storage | Served By | +|------|--------|---------|-----------| +| **Base** | Built-in with eXeLearning | Server `/perm/themes/base/` | Server | +| **Site** | Admin-installed for all users | Server `/perm/themes/site/` | Server | +| **User** | Imported by user or from .elpx | Client IndexedDB + Yjs | **Never server** | + +### 13.2 Server Themes (Base & Site) + +**Base themes** are included with eXeLearning and synchronized at startup: +- Located in `/public/files/perm/themes/base/` +- Cannot be modified by users +- Served directly by the server + +**Site themes** are installed by administrators for all users: +- Located in `/perm/themes/site/` +- Admin can activate/deactivate themes +- Admin can set a default theme for new projects +- Served directly by the server + +### 13.3 User Themes (Client-Side Only) + +> **Important**: User themes are NEVER stored or served by the server. + +User themes are stored entirely on the client side: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ USER THEME STORAGE │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ IndexedDB (per-user isolation) │ +│ └── user-themes store: key = "userId:themeName" │ +│ └── Each user's themes isolated by userId prefix │ +│ └── User "alice" cannot see user "bob"'s themes │ +│ │ +│ Yjs themeFiles (project document) │ +│ └── Currently selected user theme (for collaboration/export) │ +│ │ +│ .elpx export │ +│ └── Embedded theme files (for portability) │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 13.4 User Theme Flow + +``` +1. IMPORT THEME + User uploads ZIP → Stored in IndexedDB (local storage) + +2. SELECT THEME + User selects theme → Copied to Yjs themeFiles + (enables collaboration and export) + +3. CHANGE TO ANOTHER THEME + User selects different theme → Removed from Yjs + (but remains in IndexedDB for future use) + +4. EXPORT PROJECT (.elpx) + If user theme selected → Embedded in ZIP + +5. OPEN PROJECT WITH EMBEDDED THEME + Another user opens .elpx → Theme extracted to their IndexedDB + (if ONLINE_THEMES_INSTALL is enabled) +``` + +### 13.5 Admin Configuration + +```bash +# Allow users to import/install styles +ONLINE_THEMES_INSTALL=1 # 1 = enabled (default), 0 = disabled +``` + +When disabled (`ONLINE_THEMES_INSTALL=0`): +- Users cannot import external themes via the interface +- Users cannot open .elpx files with embedded themes + +### 13.6 Why User Themes Are Client-Side + +This design follows the same pattern as other user-specific data (like favorite iDevices): + +1. **Per-user storage**: Each user's themes are private to them +2. **No server storage**: Themes don't consume server disk space +3. **Collaboration via Yjs**: Selected theme is shared with collaborators in real-time +4. **Portability**: Themes embedded in .elpx can be opened anywhere +5. **Offline capability**: Themes work without server connectivity + --- ## Further Reading @@ -539,4 +632,5 @@ ws.on('open', async () => { - [Real-Time Collaboration](development/real-time.md) - WebSocket and Yjs details - [REST API](development/rest-api.md) - API endpoints - [Testing](development/testing.md) - Test patterns and coverage +- [Creating Styles](development/styles.md) - How to create custom themes diff --git a/doc/development/styles.md b/doc/development/styles.md index af785f7a9..b7d3a90a0 100644 --- a/doc/development/styles.md +++ b/doc/development/styles.md @@ -169,22 +169,42 @@ Common functionality found in built-in eXe styles: --- +## Theme Types + +eXeLearning has three types of themes: + +| Type | Source | Storage | Served By | +|------|--------|---------|-----------| +| **Base** | Built-in with eXeLearning | Server `/perm/themes/base/` | Server | +| **Site** | Admin-installed for all users | Server `/perm/themes/site/` | Server | +| **User** | Imported by user or from .elpx | Client IndexedDB + Yjs | **Never server** | + +--- + ## Deployment Information +### Base themes (built-in) + The styles included by default in eXeLearning are located in: ``` /public/files/perm/themes/base/ ``` -If you are managing an online instance of eXeLearning, place the folder containing your new styles there and restart the service. +These are synchronized at server startup and cannot be modified by users. -User-installed styles (both in the online version, if allowed by the administrator, and in the desktop version) are stored, for each user, in: +### Site themes (admin-installed) + +Administrators can install themes for all users by placing them in: ``` -/public/files/perm/themes/users/ +/perm/themes/site/ ``` +Site themes can be: +- Activated/deactivated by the administrator +- Set as the default theme for new projects + ### Using custom styles with Docker To bind a custom style directly in `docker-compose.yml`, add the following volume: @@ -200,14 +220,56 @@ This makes the style available to **all users**. This is required because eXeLearning recreates the entire `/base/` themes directory when restarting the server. Any style not bound as a volume would be overwritten during this process. -### User styles +--- + +## User Styles (Client-Side) + +> **Important**: User themes are NEVER stored or served by the server. -User styles are those imported through the application interface (**Styles → Imported**). +User styles are imported through the application interface (**Styles → Imported**) and stored entirely on the client side. -Their final location on disk is: +### Storage locations ``` -/public/files/perm/themes/users/user +IndexedDB (browser, per-user) +└── user-themes store: key = "userId:themeName" + └── Each user's themes are isolated by userId prefix + └── Switching users shows only that user's themes + +Yjs themeFiles (project document) +└── Currently selected user theme (for collaboration/export) + +.elpx export +└── Embedded theme files (for portability) ``` -These styles are user-specific and are not affected by the regeneration of the base themes directory. +**Per-user isolation**: When user "alice" logs in, she only sees her themes. If "bob" logs in on the same browser, he sees his own themes, not Alice's. This is achieved by storing themes with a composite key `userId:themeName` in IndexedDB. + +### How user themes work + +1. **Import**: User uploads ZIP → Stored in IndexedDB (local browser storage) +2. **Select**: User selects theme → Copied to Yjs `themeFiles` (for collaboration/export) +3. **Change**: User selects different theme → Removed from Yjs (but kept in IndexedDB) +4. **Export**: If user theme is selected → Embedded in .elpx ZIP +5. **Open**: Another user opens .elpx → Theme extracted to their IndexedDB + +### Admin configuration + +```bash +# Allow users to import/install styles +ONLINE_THEMES_INSTALL=1 # 1 = enabled (default), 0 = disabled +``` + +When disabled (`ONLINE_THEMES_INSTALL=0`): +- Users **cannot** import external themes via the interface +- Users **cannot** open .elpx files with embedded themes + +### Why user themes are client-side + +This design follows the same pattern as other user-specific data (like favorite iDevices): + +1. **Per-user storage**: Each user's themes are private to them +2. **No server storage**: Themes don't consume server disk space +3. **Collaboration via Yjs**: Selected theme is shared with collaborators in real-time +4. **Portability**: Themes embedded in .elpx can be opened anywhere +5. **Offline capability**: Themes work without server connectivity diff --git a/main.js b/main.js index 81bd70be2..fd1a2084c 100644 --- a/main.js +++ b/main.js @@ -1,20 +1,48 @@ -const { app, BrowserWindow, dialog, session, ipcMain, Menu, systemPreferences, shell } = require('electron'); +const { app, BrowserWindow, dialog, session, ipcMain, Menu, systemPreferences, shell, protocol, net } = require('electron'); const { autoUpdater } = require('electron-updater'); const log = require('electron-log'); const path = require('path'); const i18n = require('i18n'); -const { spawn, execFileSync } = require('child_process'); const fs = require('fs'); const fflate = require('fflate'); -const http = require('http'); // Import the http module to check server availability and downloads const https = require('https'); +const { pathToFileURL } = require('url'); const { initAutoUpdater } = require('./update-manager'); +// Register exe:// protocol as privileged (must be done before app ready) +// This allows the protocol to: +// - Be treated as secure origin (like https) +// - Support fetch, XHR, and other web APIs +// - Bypass CORS restrictions for local files +protocol.registerSchemesAsPrivileged([ + { + scheme: 'exe', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + }, + }, +]); + // Determine the base path depending on whether the app is packaged when we enable "asar" packaging const basePath = app.isPackaged ? process.resourcesPath : app.getAppPath(); +/** + * Get the path to the static files directory. + * In packaged mode, static files are in extraResources/static/. + * In dev mode, static files are in dist/static/. + */ +function getStaticPath() { + return app.isPackaged + ? path.join(process.resourcesPath, 'static') + : path.join(__dirname, 'dist', 'static'); +} + // Optional: force a predictable path/name log.transports.file.resolvePathFn = () => path.join(app.getPath('userData'), 'logs', 'main.log'); @@ -65,14 +93,9 @@ i18n.configure({ i18n.setLocale(defaultLocale); let appDataPath; -let databasePath; - -let databaseUrl; let mainWindow; -let loadingWindow; let isShuttingDown = false; // Flag to ensure the app only shuts down once -let serverProcess = null; // Elysia server process handle let updaterInited = false; // guard // Environment variables container @@ -288,44 +311,21 @@ function ensureAllDirectoriesWritable(env) { function initializePaths() { appDataPath = app.getPath('userData'); - databasePath = path.join(appDataPath, 'exelearning.db'); - console.log(`APP data path: ${appDataPath}`); - console.log('Database path:', databasePath); } // Define environment variables after initializing paths +// Note: In static mode, we only need directory paths for cache/cleanup function initializeEnv() { const isDev = determineDevMode(); const appEnv = isDev ? 'dev' : 'prod'; - // For Electron mode, use port 3001 for local development - const serverPort = '3001'; - // Get the appropriate app data path based on platform customEnv = { APP_ENV: process.env.APP_ENV || appEnv, APP_DEBUG: process.env.APP_DEBUG ?? (isDev ? 1 : 0), EXELEARNING_DEBUG_MODE: (process.env.EXELEARNING_DEBUG_MODE ?? (isDev ? '1' : '0')).toString(), - APP_SECRET: process.env.APP_SECRET || 'CHANGE_THIS_FOR_A_SECRET', - APP_PORT: serverPort, - APP_ONLINE_MODE: process.env.APP_ONLINE_MODE ?? '0', - APP_AUTH_METHODS: process.env.APP_AUTH_METHODS || 'none', - TEST_USER_EMAIL: process.env.TEST_USER_EMAIL || 'user@exelearning.net', - TEST_USER_USERNAME: process.env.TEST_USER_USERNAME || 'user', - TEST_USER_PASSWORD: process.env.TEST_USER_PASSWORD || '1234', - TRUSTED_PROXIES: process.env.TRUSTED_PROXIES || '', - MAILER_DSN: process.env.MAILER_DSN || 'smtp://localhost', - CAS_URL: process.env.CAS_URL || '', - DB_DRIVER: process.env.DB_DRIVER || 'pdo_sqlite', - DB_CHARSET: process.env.DB_CHARSET || 'utf8', - DB_PATH: process.env.DB_PATH || databasePath, - DB_SERVER_VERSION: process.env.DB_SERVER_VERSION || '3.32', FILES_DIR: path.join(appDataPath, 'data'), CACHE_DIR: path.join(appDataPath, 'cache'), LOG_DIR: path.join(appDataPath, 'log'), - API_JWT_SECRET: process.env.API_JWT_SECRET || 'CHANGE_THIS_FOR_A_SECRET', - ONLINE_THEMES_INSTALL: 1, - ONLINE_IDEVICES_INSTALL: 0, // To do (see #381) - BASE_PATH: process.env.BASE_PATH || '/', }; } /** @@ -372,13 +372,6 @@ function applyCombinedEnvToProcess() { Object.assign(process.env, env || {}); } -function getServerPort() { - try { - return Number(customEnv?.APP_PORT || process.env.APP_PORT || 3001); - } catch (_e) { - return 3001; - } -} // Detecta si una URL es externa (debe abrirse en navegador del sistema) function isExternalUrl(url) { @@ -483,310 +476,263 @@ function createWindow() { // Ensure all required directories exist and try to set permissions ensureAllDirectoriesWritable(env); - // Create the loading window - createLoadingWindow(); + // Register exe:// protocol handler to serve static files + // This allows the app to load files with proper origin (exe://static) + // which enables fetch, CORS, and blob URL resolution in previews + const staticDir = getStaticPath(); + protocol.handle('exe', (request) => { + // Parse the URL: exe://./path/to/file -> staticDir/path/to/file + const url = new URL(request.url); + // Remove leading ./ or / from pathname + let filePath = url.pathname.replace(/^\/+/, ''); + const fullPath = path.join(staticDir, filePath); + + // Security: ensure the path is within staticDir + const normalizedPath = path.normalize(fullPath); + if (!normalizedPath.startsWith(staticDir)) { + return new Response('Forbidden', { status: 403 }); + } + + // Use net.fetch to serve the file (handles MIME types automatically) + return net.fetch(pathToFileURL(normalizedPath).href); + }); + console.log('Registered exe:// protocol handler for:', staticDir); - // Start the Elysia server only in production (in dev, assume it's already running) const isDev = determineDevMode(); - if (!isDev) { - startElysiaServer(); - } else { - console.log('Development mode: skipping server startup (assuming external server running)'); - } - // Wait for the server to be available before loading the main window - waitForServer(() => { - // Close the loading window - if (loadingWindow) { - loadingWindow.close(); - } + // Create the main window (no server needed - load static files directly) + mainWindow = new BrowserWindow({ + width: 1250, + height: 800, + autoHideMenuBar: !isDev, // Windows / Linux + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, 'preload.js'), + }, + tabbingIdentifier: 'mainGroup', + show: true, + }); - const isDev = determineDevMode(); + // Show the menu bar in development mode, hide it in production + mainWindow.setMenuBarVisibility(isDev); - // Create the main window - mainWindow = new BrowserWindow({ - width: 1250, - height: 800, - autoHideMenuBar: !isDev, // Windows / Linux - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - preload: path.join(__dirname, 'preload.js'), - }, - tabbingIdentifier: 'mainGroup', - show: true, - // titleBarStyle: 'customButtonsOnHover', // hidden title bar on macOS - }); + // Maximize the window and open it + mainWindow.maximize(); + mainWindow.show(); - // Show the menu bar in development mode, hide it in production - mainWindow.setMenuBarVisibility(isDev); + // macOS: Show tab bar after window is visible + if (process.platform === 'darwin' && typeof mainWindow.toggleTabBar === 'function') { + // Small delay to ensure window is fully rendered + setTimeout(() => { + try { + mainWindow.toggleTabBar(); + } catch (e) { + console.warn('Could not toggle tab bar:', e.message); + } + }, 100); + } - // Maximize the window and open it - mainWindow.maximize(); + if (process.env.CI === '1' || process.env.CI === 'true') { + mainWindow.setAlwaysOnTop(true, 'screen-saver'); mainWindow.show(); + mainWindow.focus(); + setTimeout(() => mainWindow.setAlwaysOnTop(false), 2500); + } - // macOS: Show tab bar after window is visible - if (process.platform === 'darwin' && typeof mainWindow.toggleTabBar === 'function') { - // Small delay to ensure window is fully rendered - setTimeout(() => { - try { - mainWindow.toggleTabBar(); - } catch (e) { - console.warn('Could not toggle tab bar:', e.message); - } - }, 100); - } + // Allow the child windows to be created and ensure proper closing behavior + mainWindow.webContents.on('did-create-window', childWindow => { + console.log('Child window created'); - if (process.env.CI === '1' || process.env.CI === 'true') { - mainWindow.setAlwaysOnTop(true, 'screen-saver'); - mainWindow.show(); - mainWindow.focus(); - setTimeout(() => mainWindow.setAlwaysOnTop(false), 2500); - } + // Adjust child window position slightly offset from the main window + const [mainWindowX, mainWindowY] = mainWindow.getPosition(); + const x = mainWindowX + 10; + const y = mainWindowY + 10; + childWindow.setPosition(x, y); - // Allow the child windows to be created and ensure proper closing behavior - mainWindow.webContents.on('did-create-window', childWindow => { - console.log('Child window created'); - - // Adjust child window position slightly offset from the main window - const [mainWindowX, mainWindowY] = mainWindow.getPosition(); - const x = mainWindowX + 10; - const y = mainWindowY + 10; - childWindow.setPosition(x, y); - - // Remove preventDefault if you want the window to close when clicking the X button - childWindow.on('close', () => { - // Optional: Add any cleanup actions here if necessary - console.log('Child window closed'); - childWindow.destroy(); - }); + // Remove preventDefault if you want the window to close when clicking the X button + childWindow.on('close', () => { + // Optional: Add any cleanup actions here if necessary + console.log('Child window closed'); + childWindow.destroy(); }); + }); - mainWindow.loadURL(`http://localhost:${getServerPort()}`); - - // Check for updates and flush pending files - mainWindow.webContents.on('did-finish-load', () => { - // Flush pending files (opened via double-click or command line) - // Delay to allow frontend JS to initialize and register IPC handlers - if (pendingOpenFiles.length > 0) { - const filesToOpen = [...pendingOpenFiles]; - pendingOpenFiles = []; - console.log(`Flushing ${filesToOpen.length} pending file(s) to open:`, filesToOpen); - - setTimeout(() => { - if (mainWindow && !mainWindow.isDestroyed()) { - // Open first file in main window - const firstFile = filesToOpen.shift(); - if (firstFile) { - console.log('[main] Sending file to main window:', firstFile); - mainWindow.webContents.send('app:open-file', firstFile); - } - // Open remaining files in new windows/tabs - for (const filePath of filesToOpen) { - console.log('[main] Creating new window for file:', filePath); - createNewProjectWindow(filePath); - } - } - }, 1500); // Wait for frontend to fully initialize - } + // Load static HTML via exe:// protocol (enables proper origin for fetch/CORS) + mainWindow.loadURL('exe://./index.html'); - if (!updaterInited) { - try { - const updater = initAutoUpdater({ mainWindow, autoUpdater, logger: log, streamToFile }); - // Init updater once - updaterInited = true; - void updater.checkForUpdatesAndNotify().catch(err => log.warn('update check failed', err)); - } catch (e) { - log.warn?.('Failed to init updater after load', e); - } - } - }); + // Check for updates and flush pending files + mainWindow.webContents.on('did-finish-load', () => { + // Flush pending files (opened via double-click or command line) + // Delay to allow frontend JS to initialize and register IPC handlers + if (pendingOpenFiles.length > 0) { + const filesToOpen = [...pendingOpenFiles]; + pendingOpenFiles = []; + console.log(`Flushing ${filesToOpen.length} pending file(s) to open:`, filesToOpen); - // Intercept downloads: first time ask path, then overwrite same path - session.defaultSession.on('will-download', async (event, item, webContents) => { - try { - // Use the filename from the request or our override - const wc = - webContents && !webContents.isDestroyed?.() - ? webContents - : mainWindow - ? mainWindow.webContents - : null; - const wcId = wc && !wc.isDestroyed?.() ? wc.id : null; - // Deduplicate same-URL downloads triggered within a short window - try { - const url = typeof item.getURL === 'function' ? item.getURL() : undefined; - if (wcId && url) { - const now = Date.now(); - const last = lastDownloadByWC.get(wcId); - if (last && last.url === url && now - last.time < 1500) { - // Cancel duplicate download attempt - event.preventDefault(); - return; - } - lastDownloadByWC.set(wcId, { url, time: now }); + setTimeout(() => { + if (mainWindow && !mainWindow.isDestroyed()) { + // Open first file in main window + const firstFile = filesToOpen.shift(); + if (firstFile) { + console.log('[main] Sending file to main window:', firstFile); + mainWindow.webContents.send('app:open-file', firstFile); } - } catch (_e) {} - const overrideName = wcId ? nextDownloadNameByWC.get(wcId) : null; - if (wcId && nextDownloadNameByWC.has(wcId)) nextDownloadNameByWC.delete(wcId); - const suggestedName = overrideName || item.getFilename() || 'document.elpx'; - // Determine a safe target WebContents (can be null in some cases) - // Allow renderer to define a project key (optional) - let projectKey = 'default'; - if (wcId && nextDownloadKeyByWC.has(wcId)) { - projectKey = nextDownloadKeyByWC.get(wcId) || 'default'; - nextDownloadKeyByWC.delete(wcId); - } else if (wc) { - try { - projectKey = await wc.executeJavaScript('window.__currentProjectId || "default"', true); - } catch (_e) { - // ignore, fallback to default + // Open remaining files in new windows/tabs + for (const filePath of filesToOpen) { + console.log('[main] Creating new window for file:', filePath); + createNewProjectWindow(filePath); } } + }, 1500); // Wait for frontend to fully initialize + } - let targetPath = getSavedPath(projectKey); - - if (!targetPath) { - const owner = wc ? BrowserWindow.fromWebContents(wc) : mainWindow; - const { filePath, canceled } = await dialog.showSaveDialog(owner, { - title: tOrDefault( - 'save.dialogTitle', - defaultLocale === 'es' ? 'Guardar proyecto' : 'Save project', - ), - defaultPath: suggestedName, - buttonLabel: tOrDefault('save.button', defaultLocale === 'es' ? 'Guardar' : 'Save'), - }); - if (canceled || !filePath) { + if (!updaterInited) { + try { + const updater = initAutoUpdater({ mainWindow, autoUpdater, logger: log, streamToFile }); + // Init updater once + updaterInited = true; + void updater.checkForUpdatesAndNotify().catch(err => log.warn('update check failed', err)); + } catch (e) { + log.warn?.('Failed to init updater after load', e); + } + } + }); + + // Intercept downloads: first time ask path, then overwrite same path + session.defaultSession.on('will-download', async (event, item, webContents) => { + try { + // Use the filename from the request or our override + const wc = + webContents && !webContents.isDestroyed?.() + ? webContents + : mainWindow + ? mainWindow.webContents + : null; + const wcId = wc && !wc.isDestroyed?.() ? wc.id : null; + // Deduplicate same-URL downloads triggered within a short window + try { + const url = typeof item.getURL === 'function' ? item.getURL() : undefined; + if (wcId && url) { + const now = Date.now(); + const last = lastDownloadByWC.get(wcId); + if (last && last.url === url && now - last.time < 1500) { + // Cancel duplicate download attempt event.preventDefault(); return; } - targetPath = ensureExt(filePath, suggestedName); - setSavedPath(projectKey, targetPath); - } else { - // If remembered path has no extension, append inferred one - const fixed = ensureExt(targetPath, suggestedName); - if (fixed !== targetPath) { - targetPath = fixed; - setSavedPath(projectKey, targetPath); - } + lastDownloadByWC.set(wcId, { url, time: now }); } + } catch (_e) {} + const overrideName = wcId ? nextDownloadNameByWC.get(wcId) : null; + if (wcId && nextDownloadNameByWC.has(wcId)) nextDownloadNameByWC.delete(wcId); + const suggestedName = overrideName || item.getFilename() || 'document.elpx'; + // Determine a safe target WebContents (can be null in some cases) + // Allow renderer to define a project key (optional) + let projectKey = 'default'; + if (wcId && nextDownloadKeyByWC.has(wcId)) { + projectKey = nextDownloadKeyByWC.get(wcId) || 'default'; + nextDownloadKeyByWC.delete(wcId); + } else if (wc) { + try { + projectKey = await wc.executeJavaScript('window.__currentProjectId || "default"', true); + } catch (_e) { + // ignore, fallback to default + } + } - // Save directly (overwrite without prompting) - item.setSavePath(targetPath); - - // Progress feedback and auto-resume on interruption - item.on('updated', (_e, state) => { - if (state === 'progressing') { - if (wc && !wc.isDestroyed?.()) - wc.send('download-progress', { - received: item.getReceivedBytes(), - total: item.getTotalBytes(), - }); - } else if (state === 'interrupted') { - try { - if (item.canResume()) item.resume(); - } catch (_err) {} - } + let targetPath = getSavedPath(projectKey); + + if (!targetPath) { + const owner = wc ? BrowserWindow.fromWebContents(wc) : mainWindow; + const { filePath, canceled } = await dialog.showSaveDialog(owner, { + title: tOrDefault( + 'save.dialogTitle', + defaultLocale === 'es' ? 'Guardar proyecto' : 'Save project', + ), + defaultPath: suggestedName, + buttonLabel: tOrDefault('save.button', defaultLocale === 'es' ? 'Guardar' : 'Save'), }); - - item.once('done', (_e, state) => { - const send = payload => { - if (wc && !wc.isDestroyed?.()) wc.send('download-done', payload); - else if (mainWindow && !mainWindow.isDestroyed()) - mainWindow.webContents.send('download-done', payload); - }; - if (state === 'completed') { - send({ ok: true, path: targetPath }); - return; - } - if (state === 'interrupted') { - try { - const total = item.getTotalBytes() || 0; - const exists = fs.existsSync(targetPath); - const size = exists ? fs.statSync(targetPath).size : 0; - if (exists && (total === 0 || size >= total)) { - send({ ok: true, path: targetPath }); - return; - } - } catch (_err) {} - } - send({ ok: false, error: state }); - }); - } catch (err) { - event.preventDefault(); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('download-done', { ok: false, error: err.message }); + if (canceled || !filePath) { + event.preventDefault(); + return; + } + targetPath = ensureExt(filePath, suggestedName); + setSavedPath(projectKey, targetPath); + } else { + // If remembered path has no extension, append inferred one + const fixed = ensureExt(targetPath, suggestedName); + if (fixed !== targetPath) { + targetPath = fixed; + setSavedPath(projectKey, targetPath); } } - }); - // If any event blocks window closing, remove it - mainWindow.on('close', e => { - // This is to ensure any preventDefault() won't stop the closing - console.log('Window is being forced to close...'); - e.preventDefault(); // Optional: Prevent default close event - mainWindow.destroy(); // Force destroy the window - }); + // Save directly (overwrite without prompting) + item.setSavePath(targetPath); + + // Progress feedback and auto-resume on interruption + item.on('updated', (_e, state) => { + if (state === 'progressing') { + if (wc && !wc.isDestroyed?.()) + wc.send('download-progress', { + received: item.getReceivedBytes(), + total: item.getTotalBytes(), + }); + } else if (state === 'interrupted') { + try { + if (item.canResume()) item.resume(); + } catch (_err) {} + } + }); - mainWindow.on('closed', () => { - mainWindow = null; - }); + item.once('done', (_e, state) => { + const send = payload => { + if (wc && !wc.isDestroyed?.()) wc.send('download-done', payload); + else if (mainWindow && !mainWindow.isDestroyed()) + mainWindow.webContents.send('download-done', payload); + }; + if (state === 'completed') { + send({ ok: true, path: targetPath }); + return; + } + if (state === 'interrupted') { + try { + const total = item.getTotalBytes() || 0; + const exists = fs.existsSync(targetPath); + const size = exists ? fs.statSync(targetPath).size : 0; + if (exists && (total === 0 || size >= total)) { + send({ ok: true, path: targetPath }); + return; + } + } catch (_err) {} + } + send({ ok: false, error: state }); + }); + } catch (err) { + event.preventDefault(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('download-done', { ok: false, error: err.message }); + } + } + }); - // Listen for application exit events - handleAppExit(); + // If any event blocks window closing, remove it + mainWindow.on('close', e => { + // This is to ensure any preventDefault() won't stop the closing + console.log('Window is being forced to close...'); + e.preventDefault(); // Optional: Prevent default close event + mainWindow.destroy(); // Force destroy the window }); -} -function createLoadingWindow() { - loadingWindow = new BrowserWindow({ - width: 400, - height: 300, - frame: false, // No title bar - transparent: true, // Make the window transparent - alwaysOnTop: true, // Always on top - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - }, + mainWindow.on('closed', () => { + mainWindow = null; }); - // Load the loading.html file - loadingWindow.loadFile(path.join(basePath, 'public', 'loading.html')); + // Listen for application exit events + handleAppExit(); } -function waitForServer(callback) { - // Use the BASE_PATH to check the correct healthcheck endpoint - // Handle both '/' and '/web/exelearning' style paths - const rawBasePath = customEnv?.BASE_PATH || '/'; - const urlBasePath = rawBasePath === '/' ? '' : rawBasePath; - const options = { - host: 'localhost', - port: getServerPort(), - path: `${urlBasePath}/healthcheck`, - timeout: 1000, // 1-second timeout - }; - - const checkServer = () => { - const req = http.request(options, res => { - if (res.statusCode >= 200 && res.statusCode <= 400) { - console.log('Application server available.'); - callback(); // Call the callback to continue opening the window - } else { - console.log(`Server status: ${res.statusCode}. Retrying...`); - setTimeout(checkServer, 1000); // Try again in 1 second - } - }); - - req.on('error', () => { - console.log('Server not available, retrying...'); - setTimeout(checkServer, 1000); // Try again in 1 second - }); - - req.end(); - }; - - checkServer(); -} /** * Stream a URL to a file path using Node http/https, preserving Electron session cookies. @@ -802,11 +748,14 @@ function streamToFile(downloadUrl, targetPath, wc, redirects = 0) { return new Promise(async resolve => { try { // Resolve absolute URL (support relative paths from renderer) - let baseOrigin = `http://localhost:${getServerPort() || 80}/`; + // In static mode, we only support absolute URLs (https://) + let baseOrigin = 'https://localhost/'; try { if (wc && !wc.isDestroyed?.()) { const current = wc.getURL?.(); - if (current) baseOrigin = current; + if (current && !current.startsWith('file://')) { + baseOrigin = current; + } } } catch (_e) {} let urlObj; @@ -815,6 +764,8 @@ function streamToFile(downloadUrl, targetPath, wc, redirects = 0) { } catch (_e) { urlObj = new URL(downloadUrl, baseOrigin); } + // Select HTTP or HTTPS client based on URL protocol + const http = require('http'); const client = urlObj.protocol === 'https:' ? https : http; // Build Cookie header from Electron session let cookieHeader = ''; @@ -1101,7 +1052,7 @@ app.on('new-window-for-tab', () => { }); newWindow.setMenuBarVisibility(isDev); - newWindow.loadURL(`http://localhost:${getServerPort()}`); + newWindow.loadURL('exe://./index.html'); attachOpenHandler(newWindow); @@ -1132,13 +1083,6 @@ function handleAppExit() { if (isShuttingDown) return; isShuttingDown = true; - // Kill the server process if it's running - if (serverProcess && !serverProcess.killed) { - console.log('Stopping Elysia server...'); - serverProcess.kill('SIGTERM'); - serverProcess = null; - } - if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.destroy(); } @@ -1332,98 +1276,6 @@ ipcMain.handle('app:saveBufferAs', async (e, { base64Data, projectKey, suggested } }); -function checkAndCreateDatabase() { - if (!fs.existsSync(databasePath)) { - console.log('The database does not exist. Creating the database...'); - // Add code to create the database if necessary - fs.openSync(databasePath, 'w'); // Allow read and write for all users - } else { - console.log('The database already exists.'); - } -} - -/** - * Starts the Elysia backend as a standalone executable (built with bun build --compile). - * The server runs as an external process, not in-process. - */ -function startElysiaServer() { - try { - const isWindows = process.platform === 'win32'; - const isLinux = process.platform === 'linux'; - const arch = process.arch; // 'arm64' or 'x64' - - // Determine executable name based on platform and architecture - let execName; - if (isWindows) { - execName = 'exelearning-server.exe'; - } else if (isLinux) { - execName = 'exelearning-server-linux'; - } else { - // macOS - use architecture-specific executable for universal app support - execName = arch === 'arm64' ? 'exelearning-server-arm64' : 'exelearning-server-x64'; - } - - const candidates = [ - // ExtraResources path (outside asar) - packaged app - path.join(process.resourcesPath, 'dist', execName), - // Dev path - path.join(__dirname, 'dist', execName), - ]; - - const serverBinary = candidates.find(p => fs.existsSync(p)); - if (!serverBinary) { - showErrorDialog('Server executable not found. Run "bun run build:standalone" before packaging.'); - app.quit(); - return; - } - - const port = getServerPort(); - console.log(`Starting Elysia server from ${serverBinary} on port ${port}`); - - // Build environment for the server process - const serverEnv = { - ...process.env, - APP_PORT: String(port), - DB_PATH: customEnv?.DB_PATH || databasePath, - FILES_DIR: customEnv?.FILES_DIR || path.join(appDataPath, 'data'), - APP_ONLINE_MODE: '0', - APP_SECRET: customEnv?.APP_SECRET || 'CHANGE_THIS_FOR_A_SECRET', - API_JWT_SECRET: customEnv?.API_JWT_SECRET || 'CHANGE_THIS_FOR_A_SECRET', - APP_VERSION: `v${app.getVersion()}`, - }; - - serverProcess = spawn(serverBinary, [], { - env: serverEnv, - stdio: ['ignore', 'pipe', 'pipe'], - cwd: app.isPackaged ? process.resourcesPath : __dirname, - }); - - serverProcess.stdout.on('data', data => { - console.log(`[Server] ${data.toString().trim()}`); - }); - - serverProcess.stderr.on('data', data => { - console.error(`[Server] ${data.toString().trim()}`); - }); - - serverProcess.on('error', err => { - console.error('Failed to start server process:', err); - showErrorDialog(`Failed to start server: ${err.message}`); - app.quit(); - }); - - serverProcess.on('close', code => { - if (code !== 0 && code !== null && !isShuttingDown) { - console.error(`Server process exited unexpectedly with code ${code}`); - } - serverProcess = null; - }); - } catch (err) { - console.error('Error starting Elysia server:', err); - showErrorDialog(`Error starting server: ${err.message}`); - app.quit(); - } -} /** * Create a new window for a project file @@ -1461,7 +1313,7 @@ function createNewProjectWindow(filePath) { }); newWindow.setMenuBarVisibility(isDev); - newWindow.loadURL(`http://localhost:${getServerPort()}`); + newWindow.loadURL('exe://./index.html'); // macOS: Show tab bar after window is visible if (process.platform === 'darwin' && typeof newWindow.toggleTabBar === 'function') { diff --git a/package.json b/package.json index b79b85de7..567e4ff40 100644 --- a/package.json +++ b/package.json @@ -1,308 +1,287 @@ { - "name": "exelearning", - "version": "0.0.0-alpha", - "license": "AGPL-3.0-or-later", - "description": "eXeLearning 3 is an AGPL-licensed free/libre tool to create and publish open educational resources.", - "main": "main.js", - "homepage": "https://exelearning.net", - "type": "commonjs", - "scripts": { - "start": "bun run dist/index.js", - "start:dev": "bun --watch src/index.ts", - "start:local": "cross-env FILES_DIR=data/ DB_PATH=data/exelearning.db PORT=8080 APP_ONLINE_MODE=1 bun run start:dev", - "dev": "concurrently --names 'ELYSIA,SCSS' --prefix-colors 'blue,magenta' 'bun run start:dev' 'bun run sass:watch'", - "dev:local": "concurrently --names 'ELYSIA,SCSS' --prefix-colors 'blue,magenta' 'bun run start:local' 'bun run sass:watch'", - "build": "bun build src/index.ts --outdir dist --target bun && bun build src/cli/index.ts --outfile dist/cli.js --target bun", - "build:standalone": "bun scripts/build-standalone.js", - "build:all": "bun run build && bun run css:node && bun run bundle:app && bun run bundle:exporters && bun run bundle:resources", - "css:node": "bunx sass assets/styles/main.scss public/style/workarea/main.css --style=compressed --no-source-map", - "sass:watch": "bunx sass --watch assets/styles/main.scss:public/style/workarea/main.css --style=expanded --embed-source-map", - "bundle:app": "bun build public/app/app.js --outfile public/app/app.bundle.js --minify --target browser --format iife", - "bundle:exporters": "bun scripts/build-exporters-bundle.js", - "bundle:resources": "bun scripts/build-resource-bundles.js", - "upload:bundles": "bun scripts/upload-bundle-analysis.js", - "predev": "bun scripts/setup-local.js", - "seed": "bun run src/db/seed.ts", - "cli": "bun run dist/cli.js", - "convert-elp": "bun run dist/cli.js elp:convert", - "export-elpx": "bun run dist/cli.js elp:export", - "export-html5": "bun run dist/cli.js elp:export --format=html5", - "export-html5-sp": "bun run dist/cli.js elp:export --format=html5-sp", - "export-scorm12": "bun run dist/cli.js elp:export --format=scorm12", - "export-scorm2004": "bun run dist/cli.js elp:export --format=scorm2004", - "export-ims": "bun run dist/cli.js elp:export --format=ims", - "export-epub3": "bun run dist/cli.js elp:export --format=epub3", - "test:unit": "bun test ./src ./test/helpers --coverage", - "test:unit:ci": "bun test ./src ./test/helpers --coverage --coverage-reporter=lcov --coverage-dir=coverage/bun --reporter=junit --reporter-outfile=coverage/bun/junit.xml", - "test:integration": "bun test ./test/integration", - "test:frontend": "vitest run --config vitest.config.mts --coverage --reporter=default --reporter=junit --outputFile=coverage/vitest/junit.xml", - "test:frontend:ui": "vitest --ui --config vitest.config.mts", - "test:frontend:legacy": "npx vitest run --config vitest.config.mts --coverage", - "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui", - "electron": "electron .", - "electron:dev": "bun scripts/run-electron-dev.js", - "start:app-backend": "cross-env APP_ONLINE_MODE=0 APP_PORT=3001 PORT=3001 FILES_DIR=data/ DB_PATH=data/exelearning.db bun run start:dev", - "dev:app": "concurrently --kill-others --names 'BACKEND,ELECTRON' --prefix-colors 'blue,green' 'bun run start:app-backend' 'wait-on http://localhost:3001/healthcheck && bun run electron:dev'", - "electron:pack": "electron-builder", - "electron:pack:dir": "electron-builder --dir", - "package:prepare": "bun run build:all && bun run build:standalone", - "package:app": "bun run package:prepare && bun run electron:pack", - "lint:src": "biome check src/", - "lint:src:fix": "biome check --write src/", - "lint:test": "biome check test/", - "lint:test:fix": "biome check --write test/", - "lint:public": "biome lint public/app/", - "lint:public:fix": "biome lint --write public/app/", - "format": "biome format --write src/ test/", - "format:check": "biome format src/ test/" + "name": "exelearning", + "version": "0.0.0-alpha", + "license": "AGPL-3.0-or-later", + "description": "eXeLearning 3 is an AGPL-licensed free/libre tool to create and publish open educational resources.", + "main": "main.js", + "homepage": "https://exelearning.net", + "type": "commonjs", + "scripts": { + "start": "bun run dist/index.js", + "start:dev": "bun --watch src/index.ts", + "start:local": "cross-env FILES_DIR=data/ DB_PATH=data/exelearning.db PORT=8080 APP_ONLINE_MODE=1 bun run start:dev", + "dev": "concurrently --names 'ELYSIA,SCSS' --prefix-colors 'blue,magenta' 'bun run start:dev' 'bun run sass:watch'", + "dev:local": "concurrently --names 'ELYSIA,SCSS' --prefix-colors 'blue,magenta' 'bun run start:local' 'bun run sass:watch'", + "build": "bun build src/index.ts --outdir dist --target bun && bun build src/cli/index.ts --outfile dist/cli.js --target bun", + "build:standalone": "bun scripts/build-standalone.js", + "build:all": "bun run build && bun run css:node && bun run bundle:app && bun run bundle:exporters && bun run bundle:resources", + "build:static": "bun run build:all && bun scripts/build-static-bundle.ts", + "css:node": "bunx sass assets/styles/main.scss public/style/workarea/main.css --style=compressed --no-source-map", + "sass:watch": "bunx sass --watch assets/styles/main.scss:public/style/workarea/main.css --style=expanded --embed-source-map", + "bundle:app": "bun build public/app/app.js --outfile public/app/app.bundle.js --minify --target browser --format iife", + "bundle:exporters": "bun scripts/build-exporters-bundle.js", + "bundle:resources": "bun scripts/build-resource-bundles.js", + "upload:bundles": "bun scripts/upload-bundle-analysis.js", + "predev": "bun scripts/setup-local.js", + "seed": "bun run src/db/seed.ts", + "cli": "bun run dist/cli.js", + "convert-elp": "bun run dist/cli.js elp:convert", + "export-elpx": "bun run dist/cli.js elp:export", + "export-html5": "bun run dist/cli.js elp:export --format=html5", + "export-html5-sp": "bun run dist/cli.js elp:export --format=html5-sp", + "export-scorm12": "bun run dist/cli.js elp:export --format=scorm12", + "export-scorm2004": "bun run dist/cli.js elp:export --format=scorm2004", + "export-ims": "bun run dist/cli.js elp:export --format=ims", + "export-epub3": "bun run dist/cli.js elp:export --format=epub3", + "test:unit": "bun test ./src ./test/helpers --coverage", + "test:unit:ci": "bun test ./src ./test/helpers --coverage --coverage-reporter=lcov --coverage-dir=coverage/bun --reporter=junit --reporter-outfile=coverage/bun/junit.xml", + "test:integration": "bun test ./test/integration", + "test:frontend": "vitest run --config vitest.config.mts --coverage --reporter=default --reporter=junit --outputFile=coverage/vitest/junit.xml", + "test:frontend:ui": "vitest --ui --config vitest.config.mts", + "test:frontend:legacy": "npx vitest run --config vitest.config.mts --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "electron": "electron .", + "electron:dev": "bun scripts/run-electron-dev.js", + "start:app-backend": "cross-env APP_ONLINE_MODE=0 APP_PORT=3001 PORT=3001 FILES_DIR=data/ DB_PATH=data/exelearning.db bun run start:dev", + "dev:app": "concurrently --kill-others --names 'BACKEND,ELECTRON' --prefix-colors 'blue,green' 'bun run start:app-backend' 'wait-on http://localhost:3001/healthcheck && bun run electron:dev'", + "electron:pack": "electron-builder", + "electron:pack:dir": "electron-builder --dir", + "package:prepare": "bun run build:static", + "package:app": "bun run package:prepare && bun run electron:pack", + "lint:src": "biome check src/", + "lint:src:fix": "biome check --write src/", + "lint:test": "biome check test/", + "lint:test:fix": "biome check --write test/", + "lint:public": "biome lint public/app/", + "lint:public:fix": "biome lint --write public/app/", + "format": "biome format --write src/ test/", + "format:check": "biome format src/ test/" + }, + "keywords": [], + "author": { + "name": "INTEF", + "email": "cedec@educacion.gob.es", + "url": "https://exelearning.net" + }, + "dependencies": { + "@elysiajs/cookie": "^0.8.0", + "@elysiajs/cors": "^1.4.0", + "@elysiajs/jwt": "^1.4.0", + "@elysiajs/static": "^1.4.7", + "@sinclair/typebox": "^0.34.45", + "bcryptjs": "^3.0.3", + "chmodr": "^2.0.2", + "concurrently": "^9.2.1", + "dotenv": "^17.2.3", + "electron-log": "^5.4.3", + "electron-updater": "^6.6.2", + "elysia": "^1.4.19", + "fast-xml-parser": "^5.3.3", + "fflate": "^0.8.2", + "fs-extra": "^11.3.3", + "i18n": "^0.15.3", + "ioredis": "^5.8.2", + "jose": "^6.1.3", + "kysely": "^0.28.9", + "kysely-bun-worker": "^1.2.1", + "lib0": "^0.2.116", + "mime-types": "^3.0.2", + "mysql2": "^3.16.0", + "nunjucks": "^3.2.4", + "sass": "^1.97.1", + "uuid": "^13.0.0", + "ws": "^8.18.3", + "y-websocket": "^3.0.0", + "yjs": "^13.6.28" + }, + "devDependencies": { + "@babel/core": "^7.28.5", + "@babel/preset-env": "^7.28.5", + "@biomejs/biome": "^2.3.10", + "@codecov/bundle-analyzer": "^1.9.1", + "@electron/notarize": "^3.1.1", + "@playwright/test": "^1.57.0", + "@types/bcryptjs": "^3.0.0", + "@types/fs-extra": "^11.0.4", + "@types/mime-types": "^3.0.1", + "@types/node": "^25.0.3", + "@types/nunjucks": "^3.2.6", + "@types/uuid": "^11.0.0", + "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.0.16", + "@vitest/ui": "^4.0.16", + "cross-env": "^10.1.0", + "electron": "^39.2.7", + "electron-builder": "^26.0.12", + "esbuild": "^0.27.2", + "happy-dom": "^20.0.11", + "http-proxy-middleware": "^3.0.5", + "kill-port": "^2.0.1", + "typescript": "^5.9.3", + "vite": "^7.3.0", + "vitest": "^4.0.16", + "wait-on": "^9.0.3" + }, + "build": { + "npmRebuild": false, + "appId": "es.intef.exelearning", + "productName": "eXeLearning", + "directories": { + "output": "release", + "app": "." }, - "keywords": [], - "author": { - "name": "INTEF", - "email": "cedec@educacion.gob.es", - "url": "https://exelearning.net" + "compression": "normal", + "publish": [ + { + "provider": "github", + "owner": "exelearning", + "repo": "exelearning", + "releaseType": "prerelease", + "channel": "latest" + } + ], + "afterPack": "packaging/afterPack.js", + "afterSign": "packaging/notarize.js", + "asar": true, + "disableDefaultIgnoredFiles": true, + "files": [ + "main.js", + "preload.js", + "update-manager.js", + "node_modules/**/*", + ".env.dist", + "translations/en.json", + "translations/es.json", + "package.json" + ], + "fileAssociations": [ + { + "ext": "elpx", + "name": "eXeLearning Project", + "description": "eXeLearning project file", + "role": "Editor", + "mimeType": "application/x-exelearning-elpx" + } + ], + "extraResources": [ + { + "from": "dist/static/", + "to": "static/" + }, + { + "from": "translations/", + "to": "translations/" + }, + { + "from": "packaging/keys/", + "to": "keys/" + } + ], + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + }, + { + "target": "msi", + "arch": [ + "x64" + ] + } + ], + "icon": "public/exelearning.ico", + "legalTrademarks": "INTEF", + "signAndEditExecutable": true, + "verifyUpdateCodeSignature": false, + "signtoolOptions": { + "rfc3161TimeStampServer": "http://time.certum.pl", + "timeStampServer": "http://time.certum.pl", + "signingHashAlgorithms": [ + "sha1", + "sha256" + ] + } }, - "dependencies": { - "@elysiajs/cookie": "^0.8.0", - "@elysiajs/cors": "^1.4.0", - "@elysiajs/jwt": "^1.4.0", - "@elysiajs/static": "^1.4.7", - "@sinclair/typebox": "^0.34.45", - "bcryptjs": "^3.0.3", - "chmodr": "^2.0.2", - "concurrently": "^9.2.1", - "dotenv": "^17.2.3", - "electron-log": "^5.4.3", - "electron-updater": "^6.6.2", - "elysia": "^1.4.19", - "fast-xml-parser": "^5.3.3", - "fflate": "^0.8.2", - "fs-extra": "^11.3.3", - "i18n": "^0.15.3", - "ioredis": "^5.8.2", - "jose": "^6.1.3", - "kysely": "^0.28.9", - "kysely-bun-worker": "^1.2.1", - "lib0": "^0.2.116", - "mime-types": "^3.0.2", - "mysql2": "^3.16.0", - "nunjucks": "^3.2.4", - "sass": "^1.97.1", - "uuid": "^13.0.0", - "ws": "^8.18.3", - "y-websocket": "^3.0.0", - "yjs": "^13.6.28" + "nsis": { + "oneClick": true, + "runAfterFinish": true, + "perMachine": false, + "createDesktopShortcut": true, + "createStartMenuShortcut": true, + "shortcutName": "eXeLearning", + "preCompressedFileExtensions": [ + ".zip", + ".7z", + ".gz", + ".bz2", + ".xz" + ] }, - "devDependencies": { - "@babel/core": "^7.28.5", - "@babel/preset-env": "^7.28.5", - "@biomejs/biome": "^2.3.10", - "@codecov/bundle-analyzer": "^1.9.1", - "@electron/notarize": "^3.1.1", - "@playwright/test": "^1.57.0", - "@types/bcryptjs": "^3.0.0", - "@types/fs-extra": "^11.0.4", - "@types/mime-types": "^3.0.1", - "@types/node": "^25.0.3", - "@types/nunjucks": "^3.2.6", - "@types/uuid": "^11.0.0", - "@types/ws": "^8.18.1", - "@vitest/coverage-v8": "^4.0.16", - "@vitest/ui": "^4.0.16", - "cross-env": "^10.1.0", - "electron": "^39.2.7", - "electron-builder": "^26.0.12", - "esbuild": "^0.27.2", - "happy-dom": "^20.0.11", - "http-proxy-middleware": "^3.0.5", - "kill-port": "^2.0.1", - "typescript": "^5.9.3", - "vite": "^7.3.0", - "vitest": "^4.0.16", - "wait-on": "^9.0.3" + "msi": { + "oneClick": false, + "perMachine": true, + "runAfterFinish": false, + "createDesktopShortcut": true, + "createStartMenuShortcut": true }, - "build": { - "npmRebuild": false, - "appId": "es.intef.exelearning", - "productName": "eXeLearning", - "directories": { - "output": "release", - "app": "." - }, - "compression": "normal", - "publish": [ - { - "provider": "github", - "owner": "exelearning", - "repo": "exelearning", - "releaseType": "prerelease", - "channel": "latest" - } - ], - "afterPack": "packaging/afterPack.js", - "afterSign": "packaging/notarize.js", - "asar": true, - "disableDefaultIgnoredFiles": true, - "files": [ - "main.js", - "preload.js", - "update-manager.js", - "node_modules/**/*", - ".env.dist", - "translations/en.json", - "translations/es.json", - "dist/**/*", - "package.json" - ], - "fileAssociations": [ - { - "ext": "elpx", - "name": "eXeLearning Project", - "description": "eXeLearning project file", - "role": "Editor", - "mimeType": "application/x-exelearning-elpx" - } - ], - "extraResources": [ - { - "from": "public/", - "to": "public/", - "filter": [ - "**/*", - "!**/__tests__/**", - "!**/jest.setup.js", - "!**/*.test.js", - "!**/*.spec.js", - "!**/*.jest.test.js" - ] - }, - { - "from": "translations/", - "to": "translations/" - }, - { - "from": ".env.dist", - "to": ".env.dist" - }, - { - "from": "packaging/keys/", - "to": "keys/" - }, - { - "from": "dist/", - "to": "dist/" - }, - { - "from": "views/", - "to": "views/" - } - ], - "win": { - "target": [ - { - "target": "nsis", - "arch": [ - "x64" - ] - }, - { - "target": "msi", - "arch": [ - "x64" - ] - } - ], - "icon": "public/exelearning.ico", - "legalTrademarks": "INTEF", - "signAndEditExecutable": true, - "verifyUpdateCodeSignature": false, - "signtoolOptions": { - "rfc3161TimeStampServer": "http://time.certum.pl", - "timeStampServer": "http://time.certum.pl", - "signingHashAlgorithms": [ - "sha1", - "sha256" - ] - } - }, - "nsis": { - "oneClick": true, - "runAfterFinish": true, - "perMachine": false, - "createDesktopShortcut": true, - "createStartMenuShortcut": true, - "shortcutName": "eXeLearning", - "preCompressedFileExtensions": [ - ".zip", - ".7z", - ".gz", - ".bz2", - ".xz" - ] - }, - "msi": { - "oneClick": false, - "perMachine": true, - "runAfterFinish": false, - "createDesktopShortcut": true, - "createStartMenuShortcut": true - }, - "linux": { - "executableName": "exelearning", - "executableArgs": [ - "--no-sandbox" - ], - "target": [ - { - "target": "deb", - "arch": [ - "x64" - ] - }, - { - "target": "rpm", - "arch": [ - "x64" - ] - } - ], - "category": "Education", - "icon": "public/icons" - }, - "deb": { - "afterInstall": "packaging/deb/after-install.sh", - "afterRemove": "packaging/deb/after-remove.sh" + "linux": { + "executableName": "exelearning", + "executableArgs": [ + "--no-sandbox" + ], + "target": [ + { + "target": "deb", + "arch": [ + "x64" + ] }, - "rpm": { - "afterInstall": "packaging/rpm/after-install.sh", - "afterRemove": "packaging/rpm/after-remove.sh" - }, - "mac": { - "category": "public.app-category.education", - "hardenedRuntime": true, - "gatekeeperAssess": false, - "target": [ - { - "target": "dmg", - "arch": [ - "universal" - ] - }, - { - "target": "zip", - "arch": [ - "universal" - ] - } - ], - "icon": "public/exe_elp.icns", - "entitlements": "packaging/entitlements.mac.plist", - "entitlementsInherit": "packaging/entitlements.mac.inherit.plist", - "x64ArchFiles": "**/exelearning-server-*" + { + "target": "rpm", + "arch": [ + "x64" + ] + } + ], + "category": "Education", + "icon": "public/icons" + }, + "deb": { + "afterInstall": "packaging/deb/after-install.sh", + "afterRemove": "packaging/deb/after-remove.sh" + }, + "rpm": { + "afterInstall": "packaging/rpm/after-install.sh", + "afterRemove": "packaging/rpm/after-remove.sh" + }, + "mac": { + "category": "public.app-category.education", + "hardenedRuntime": true, + "gatekeeperAssess": false, + "target": [ + { + "target": "dmg", + "arch": [ + "universal" + ] }, - "dmg": { - "format": "ULFO" + { + "target": "zip", + "arch": [ + "universal" + ] } + ], + "icon": "public/exe_elp.icns", + "entitlements": "packaging/entitlements.mac.plist", + "entitlementsInherit": "packaging/entitlements.mac.inherit.plist" + }, + "dmg": { + "format": "ULFO" } + } } \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts index ac4af8446..730919091 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,7 +3,65 @@ import { defineConfig, devices } from '@playwright/test'; /** * Playwright E2E Test Configuration for eXeLearning * @see https://playwright.dev/docs/test-configuration + * + * Supports two modes: + * - Server mode (default): Tests against the full Elysia server + * - Static mode: Tests against the static bundle (no server) + * + * Run static mode tests with: make test-e2e-static */ + +// Detect if running static mode tests +const isStaticProject = process.env.PLAYWRIGHT_PROJECT?.includes('static'); + +/** + * Get the appropriate webServer configuration based on project type + */ +function getWebServerConfig() { + const project = process.env.PLAYWRIGHT_PROJECT || ''; + + if (process.env.E2E_BASE_URL) { + return undefined; // External server provided + } + + if (project.includes('static')) { + // Static mode: build and serve static bundle + return { + command: 'bun scripts/serve-static-for-e2e.ts', + url: 'http://localhost:8080', + reuseExistingServer: !process.env.CI, + timeout: 180000, // 3 minutes (includes build time) + stdout: 'pipe' as const, + stderr: 'pipe' as const, + env: { + ...process.env, + PORT: '8080', + }, + }; + } + + // Server mode (default) + return { + command: + 'DB_PATH=:memory: FILES_DIR=/tmp/exelearning-e2e/ PORT=3001 APP_PORT=3001 APP_AUTH_METHODS=password,guest ONLINE_THEMES_INSTALL=1 APP_LOCALE=en bun src/index.ts', + url: 'http://localhost:3001/login', + reuseExistingServer: false, // Always start fresh to ensure correct env vars + timeout: 120 * 1000, // 2 minutes to start + stdout: 'pipe' as const, + stderr: 'pipe' as const, + env: { + ...process.env, + DB_PATH: ':memory:', + FILES_DIR: '/tmp/exelearning-e2e/', + PORT: '3001', + APP_PORT: '3001', + APP_AUTH_METHODS: 'password,guest', + ONLINE_THEMES_INSTALL: '1', // Enable theme import for E2E tests + APP_LOCALE: 'en', // Force English locale for E2E tests + }, + }; +} + export default defineConfig({ testDir: './test/e2e/playwright/specs', @@ -30,7 +88,10 @@ export default defineConfig({ /* Shared settings for all the projects below */ use: { /* Base URL to use in actions like `await page.goto('/')` */ - baseURL: process.env.E2E_BASE_URL || 'http://localhost:3001', + baseURL: process.env.E2E_BASE_URL || (isStaticProject ? 'http://localhost:8080' : 'http://localhost:3001'), + + /* Force English locale for consistent test behavior */ + locale: 'en-US', /* Collect trace when retrying the failed test */ trace: 'on-first-retry', @@ -50,40 +111,33 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ + // Server mode projects (exclude static-mode tests) { name: 'chromium', + testIgnore: /static-mode-.*\.spec\.ts/, use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', + testIgnore: /static-mode-.*\.spec\.ts/, use: { ...devices['Desktop Firefox'] }, }, + // Static mode project (Chromium only) - runs all tests that don't skip via serverOnly() + { + name: 'chromium-static', + use: { + ...devices['Desktop Chrome'], + baseURL: 'http://localhost:8080', + }, + }, // { // name: 'webkit', // use: { ...devices['Desktop Safari'] }, // }, ], - /* Run local dev server before starting the tests (only if E2E_BASE_URL is not set) */ - webServer: process.env.E2E_BASE_URL - ? undefined - : { - command: - 'DB_PATH=:memory: FILES_DIR=/tmp/exelearning-e2e/ PORT=3001 APP_PORT=3001 APP_AUTH_METHODS=password,guest bun src/index.ts', - url: 'http://localhost:3001/login', - reuseExistingServer: !process.env.CI, - timeout: 120 * 1000, // 2 minutes to start - stdout: 'pipe', - stderr: 'pipe', - env: { - ...process.env, - DB_PATH: ':memory:', - FILES_DIR: '/tmp/exelearning-e2e/', - PORT: '3001', - APP_PORT: '3001', - APP_AUTH_METHODS: 'password,guest', - }, - }, + /* Run local dev server before starting the tests (conditional based on mode) */ + webServer: getWebServerConfig(), /* Global timeout for each test */ timeout: 60000, diff --git a/public/app/app.js b/public/app/app.js index b356e89b8..ea073a14b 100644 --- a/public/app/app.js +++ b/public/app/app.js @@ -18,11 +18,20 @@ import UserManager from './workarea/user/userManager.js'; import Actions from './common/app_actions.js'; import Shortcuts from './common/shortcuts.js'; import SessionMonitor from './common/sessionMonitor.js'; +import DataProvider from './core/DataProvider.js'; +// Core infrastructure - ports/adapters pattern +import { RuntimeConfig } from './core/RuntimeConfig.js'; +import { Capabilities } from './core/Capabilities.js'; +import { ProviderFactory } from './core/ProviderFactory.js'; export default class App { constructor(eXeLearning) { this.eXeLearning = eXeLearning; this.parseExelearningConfig(); + + // Detect and initialize static/offline mode + this.initializeDataProvider(); + this.api = new ApiCallManager(this); this.locale = new Locale(this); this.common = new Common(this); @@ -48,11 +57,18 @@ export default class App { * */ async init() { + // Initialize DataProvider (load static data if in static mode) + await this.dataProvider.init(); + + // Create ProviderFactory and inject adapters (Ports & Adapters pattern) + // This is the ONLY place where mode detection happens for adapters + await this.initializeAdapters(); + // Compose and initialized toasts this.initializedToasts(); // Compose and initialized modals this.initializedModals(); - // Load api routes + // Load api routes (uses DataProvider in static mode) await this.loadApiParameters(); // Load locale strings await this.loadLocale(); @@ -186,6 +202,101 @@ export default class App { }; } + /** + * Initialize DataProvider based on detected mode (static vs server) + * Called during constructor, before other managers are created + */ + initializeDataProvider() { + // Use RuntimeConfig for mode detection (single source of truth) + this.runtimeConfig = RuntimeConfig.fromEnvironment(); + this.capabilities = new Capabilities(this.runtimeConfig); + + // Backward compatibility: store mode flags in config + const isStaticMode = this.runtimeConfig.isStaticMode(); + this.eXeLearning.config.isStaticMode = isStaticMode; + + // Create DataProvider with detected mode + const mode = isStaticMode ? 'static' : 'server'; + const basePath = this.eXeLearning.config.basePath || ''; + + this.dataProvider = new DataProvider(mode, { basePath }); + + if (isStaticMode) { + console.log('[App] Running in STATIC/OFFLINE mode'); + // Ensure offline-related flags are set + this.eXeLearning.config.isOfflineInstallation = true; + } + + // Log capabilities for debugging + console.log('[App] Capabilities:', { + collaboration: this.capabilities.collaboration.enabled, + remoteStorage: this.capabilities.storage.remote, + auth: this.capabilities.auth.required, + }); + } + + /** + * Initialize adapters using ProviderFactory (Ports & Adapters pattern). + * This is the ONLY place where adapters are created and injected. + * After this, all API calls go through the appropriate adapter based on mode. + */ + async initializeAdapters() { + try { + // Create factory (mode detection happens inside) + const factory = await ProviderFactory.create(); + + // Create all adapters + const adapters = factory.createAllAdapters(); + + // Inject into ApiCallManager + this.api.setAdapters(adapters); + + // Store factory and capabilities for other components + this.providerFactory = factory; + // Update capabilities from factory (in case they differ) + this.capabilities = factory.getCapabilities(); + + console.log('[App] Adapters injected successfully:', { + mode: factory.getConfig().mode, + adaptersInjected: Object.keys(adapters).length, + }); + } catch (error) { + console.error('[App] Failed to initialize adapters:', error); + // Continue without adapters - legacy fallback code will handle it + } + } + + /** + * Detect if the app should run in static (offline) mode + * @deprecated Use this.runtimeConfig.isStaticMode() or this.capabilities.storage.remote instead + * @returns {boolean} + */ + detectStaticMode() { + // Use RuntimeConfig if available (new pattern) + if (this.runtimeConfig) { + return this.runtimeConfig.isStaticMode(); + } + + // Fallback for early initialization before RuntimeConfig is set + // Priority 1: Explicit static mode flag (set in static/index.html) + if (window.__EXE_STATIC_MODE__ === true) { + return true; + } + + // Priority 2: File protocol (opened as local file) + if (window.location.protocol === 'file:') { + return true; + } + + // Priority 3: No server URL configured + if (!this.eXeLearning.config.fullURL) { + return true; + } + + // Default: server mode + return false; + } + setupSessionMonitor() { const baseInterval = Number( this.eXeLearning.config.sessionCheckIntervalMs || @@ -309,6 +420,20 @@ export default class App { console.info('Session expired, redirecting to login.', reason); } + /** + * Check if the app is running in static/offline mode. + * @deprecated Prefer using this.capabilities for feature checks + * @returns {boolean} + */ + isStaticMode() { + // Use RuntimeConfig as primary source + if (this.runtimeConfig) { + return this.runtimeConfig.isStaticMode(); + } + // Fallback to DataProvider for backward compatibility + return this.dataProvider?.isStaticMode() ?? false; + } + /** * */ @@ -393,25 +518,41 @@ export default class App { * */ async check() { + // No server-side checks needed when remote storage is unavailable + if (!this.capabilities?.storage?.remote) { + return; + } + // Check FILES_DIR - if (!this.eXeLearning.config.filesDirPermission.checked) { + if (!this.eXeLearning.config?.filesDirPermission?.checked) { let htmlBody = ''; - this.eXeLearning.config.filesDirPermission.info.forEach((text) => { + const info = this.eXeLearning.config?.filesDirPermission?.info || []; + info.forEach((text) => { htmlBody += `

${text}

`; }); - this.modals.alert.show({ - title: _('Permissions error'), - body: htmlBody, - contentId: 'error', - }); + if (htmlBody) { + this.modals.alert.show({ + title: _('Permissions error'), + body: htmlBody, + contentId: 'error', + }); + } } } /** * Show LOPDGDD modal if necessary + * Skip LOPD modal when auth is not required (guest access) * */ async showModalLopd() { + // Skip LOPD modal when auth is not required (static/offline mode) + if (!this.capabilities?.auth?.required) { + await this.loadProject(); + this.check(); + return; + } + if (!eXeLearning.user.acceptedLopd) { // Load modals content await this.project.loadModalsContent(); @@ -789,10 +930,25 @@ function __exeInstallBeforeUnloadOnce() { /** * Run eXe client on load + * In static mode, waits for project selection before initializing * */ window.onload = function () { var eXeLearning = window.eXeLearning; eXeLearning.app = new App(eXeLearning); + + // Static mode: wait for project selection (projectId will be set by welcome screen) + // Use RuntimeConfig for early detection (before app.capabilities is available) + const runtimeConfig = RuntimeConfig.fromEnvironment(); + if (runtimeConfig.isStaticMode() && !eXeLearning.projectId) { + console.log('[App] Static mode: waiting for project selection...'); + // Expose a function to start the app after project is selected + window.__startExeApp = function () { + console.log('[App] Starting app with project:', eXeLearning.projectId); + eXeLearning.app.init(); + }; + return; + } + eXeLearning.app.init(); }; diff --git a/public/app/app.test.js b/public/app/app.test.js index 7c72d32e2..0a15e954c 100644 --- a/public/app/app.test.js +++ b/public/app/app.test.js @@ -663,6 +663,9 @@ describe('App utility methods', () => { info: ['Error 1', 'Error 2'], }; + // Mock isStaticMode to return false so we test the normal check flow + vi.spyOn(appInstance, 'isStaticMode').mockReturnValue(false); + await appInstance.check(); expect(showSpy).toHaveBeenCalledWith(expect.objectContaining({ @@ -1070,6 +1073,9 @@ describe('App utility methods', () => { const hideSpy = vi.fn(); const loadModalsContentSpy = vi.fn(); + // Mock isStaticMode to return false so we test the normal LOPD flow + vi.spyOn(appInstance, 'isStaticMode').mockReturnValue(false); + appInstance.project = { loadModalsContent: loadModalsContentSpy }; appInstance.interface = { loadingScreen: { hide: hideSpy } }; appInstance.modals = { @@ -1091,6 +1097,9 @@ describe('App utility methods', () => { const loadSpy = vi.fn(); const checkSpy = vi.spyOn(appInstance, 'check').mockImplementation(() => {}); + // Mock isStaticMode to return false so we test the normal LOPD flow + vi.spyOn(appInstance, 'isStaticMode').mockReturnValue(false); + appInstance.project = { load: loadSpy }; await appInstance.showModalLopd(); @@ -1174,7 +1183,7 @@ describe('App utility methods', () => { it('sets up session monitor for online installation', () => { window.eXeLearning = { user: '{"id":1}', - config: '{"isOfflineInstallation":false,"basePath":""}', + config: '{"isOfflineInstallation":false,"basePath":"","fullURL":"http://localhost:8080"}', }; const app = new App(window.eXeLearning); diff --git a/public/app/common/common.js b/public/app/common/common.js index 1dd489f46..e33bf628f 100644 --- a/public/app/common/common.js +++ b/public/app/common/common.js @@ -88,7 +88,11 @@ window.MathJax = window.MathJax || (function() { load: externalExtensions.map(function(ext) { return '[tex]/' + ext; }) }, options: { - // MathJax Configuration Options + // Exclude navbar dropdown menus from MathJax processing (File, Edit, etc.) + // Note: nav-element is NOT excluded - page titles with LaTeX must be processed + ignoreHtmlClass: 'tex2jax_ignore|dropdown-menu|dropdown-item|modal', + // Skip processing inside these HTML tags + skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code'] } }; })(); @@ -1498,7 +1502,11 @@ var $exeDevices = { } if (!window.MathJax.loader) window.MathJax.loader = {}; if (!window.MathJax.loader.paths) window.MathJax.loader.paths = {}; - window.MathJax.loader.paths.mathjax = basePath; + // In static mode, keep the pre-configured relative path + var capabilities = window.eXeLearning?.app?.capabilities; + if (capabilities?.storage?.remote) { + window.MathJax.loader.paths.mathjax = basePath; + } var script = document.createElement('script'); script.src = self.engine; script.async = true; diff --git a/public/app/core/Capabilities.js b/public/app/core/Capabilities.js new file mode 100644 index 000000000..676431c35 --- /dev/null +++ b/public/app/core/Capabilities.js @@ -0,0 +1,112 @@ +/** + * Capabilities - Feature flags that UI and business logic should query. + * Instead of checking mode, code should check capabilities. + * + * Example: + * // BAD: if (this.app.isStaticMode()) { ... } + * // GOOD: if (!this.app.capabilities.collaboration.enabled) { ... } + */ +export class Capabilities { + /** + * @param {import('./RuntimeConfig').RuntimeConfig} config + */ + constructor(config) { + const isServer = config.mode === 'server'; + const isStatic = config.mode === 'static'; + const isElectron = config.mode === 'electron'; + + /** + * Collaboration features (presence, real-time sync) + */ + this.collaboration = Object.freeze({ + /** Whether collaboration is available */ + enabled: isServer, + /** Whether real-time sync via WebSocket is available */ + realtime: isServer, + /** Whether presence/cursors are available */ + presence: isServer, + /** Whether concurrent editing is supported */ + concurrent: isServer, + }); + + /** + * Storage capabilities + */ + this.storage = Object.freeze({ + /** Whether remote server storage is available */ + remote: isServer, + /** Whether local storage (IndexedDB) is available */ + local: true, // Always available + /** Whether sync between local and remote is available */ + sync: isServer, + /** Whether projects are persisted to server */ + serverPersistence: isServer, + }); + + /** + * Export capabilities + */ + this.export = Object.freeze({ + /** Whether server-side export is available */ + serverSide: isServer, + /** Whether client-side export (JSZip) is available */ + clientSide: true, // Always available + }); + + /** + * Authentication capabilities + */ + this.auth = Object.freeze({ + /** Whether authentication is required */ + required: isServer, + /** Whether guest/anonymous access is allowed */ + guest: isStatic || isElectron, + /** Whether login/logout is available */ + loginAvailable: isServer, + }); + + /** + * Project management capabilities + */ + this.projects = Object.freeze({ + /** Whether project list is fetched from server */ + remoteList: isServer, + /** Whether projects are stored in IndexedDB */ + localList: isStatic || isElectron, + /** Whether "Recent Projects" uses server API */ + recentFromServer: isServer, + /** Whether "Open from server" is available */ + openFromServer: isServer, + /** Whether "Save to server" is available */ + saveToServer: isServer, + }); + + /** + * Sharing capabilities + */ + this.sharing = Object.freeze({ + /** Whether sharing is available */ + enabled: isServer, + /** Whether visibility settings are available */ + visibility: isServer, + /** Whether link sharing is available */ + links: isServer, + }); + + /** + * File management capabilities + */ + this.fileManager = Object.freeze({ + /** Whether file manager dialog is available */ + enabled: true, // Available in all modes + /** Whether file manager uses server API */ + serverBacked: isServer, + /** Whether files are stored locally */ + localBacked: isStatic || isElectron, + }); + + Object.freeze(this); + } +} + +export default Capabilities; diff --git a/public/app/core/Capabilities.test.js b/public/app/core/Capabilities.test.js new file mode 100644 index 000000000..1c15d9a0b --- /dev/null +++ b/public/app/core/Capabilities.test.js @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import { Capabilities } from './Capabilities.js'; +import { RuntimeConfig } from './RuntimeConfig.js'; + +describe('Capabilities', () => { + describe('server mode', () => { + const config = new RuntimeConfig({ + mode: 'server', + baseUrl: 'http://localhost:8080', + wsUrl: 'ws://localhost:8080', + staticDataPath: null, + }); + const capabilities = new Capabilities(config); + + it('should be immutable', () => { + expect(Object.isFrozen(capabilities)).toBe(true); + expect(Object.isFrozen(capabilities.collaboration)).toBe(true); + expect(Object.isFrozen(capabilities.storage)).toBe(true); + }); + + it('should enable collaboration features', () => { + expect(capabilities.collaboration.enabled).toBe(true); + expect(capabilities.collaboration.realtime).toBe(true); + expect(capabilities.collaboration.presence).toBe(true); + expect(capabilities.collaboration.concurrent).toBe(true); + }); + + it('should enable remote storage', () => { + expect(capabilities.storage.remote).toBe(true); + expect(capabilities.storage.local).toBe(true); + expect(capabilities.storage.sync).toBe(true); + expect(capabilities.storage.serverPersistence).toBe(true); + }); + + it('should enable both export methods', () => { + expect(capabilities.export.serverSide).toBe(true); + expect(capabilities.export.clientSide).toBe(true); + }); + + it('should require authentication', () => { + expect(capabilities.auth.required).toBe(true); + expect(capabilities.auth.guest).toBe(false); + expect(capabilities.auth.loginAvailable).toBe(true); + }); + + it('should enable remote project features', () => { + expect(capabilities.projects.remoteList).toBe(true); + expect(capabilities.projects.localList).toBe(false); + expect(capabilities.projects.recentFromServer).toBe(true); + expect(capabilities.projects.openFromServer).toBe(true); + expect(capabilities.projects.saveToServer).toBe(true); + }); + + it('should enable sharing', () => { + expect(capabilities.sharing.enabled).toBe(true); + expect(capabilities.sharing.visibility).toBe(true); + expect(capabilities.sharing.links).toBe(true); + }); + + it('should enable server-backed file manager', () => { + expect(capabilities.fileManager.enabled).toBe(true); + expect(capabilities.fileManager.serverBacked).toBe(true); + expect(capabilities.fileManager.localBacked).toBe(false); + }); + }); + + describe('static mode', () => { + const config = new RuntimeConfig({ + mode: 'static', + baseUrl: '.', + wsUrl: null, + staticDataPath: './data/bundle.json', + }); + const capabilities = new Capabilities(config); + + it('should disable collaboration features', () => { + expect(capabilities.collaboration.enabled).toBe(false); + expect(capabilities.collaboration.realtime).toBe(false); + expect(capabilities.collaboration.presence).toBe(false); + expect(capabilities.collaboration.concurrent).toBe(false); + }); + + it('should use local storage only', () => { + expect(capabilities.storage.remote).toBe(false); + expect(capabilities.storage.local).toBe(true); + expect(capabilities.storage.sync).toBe(false); + expect(capabilities.storage.serverPersistence).toBe(false); + }); + + it('should only support client-side export', () => { + expect(capabilities.export.serverSide).toBe(false); + expect(capabilities.export.clientSide).toBe(true); + }); + + it('should allow guest access', () => { + expect(capabilities.auth.required).toBe(false); + expect(capabilities.auth.guest).toBe(true); + expect(capabilities.auth.loginAvailable).toBe(false); + }); + + it('should use local project storage', () => { + expect(capabilities.projects.remoteList).toBe(false); + expect(capabilities.projects.localList).toBe(true); + expect(capabilities.projects.recentFromServer).toBe(false); + expect(capabilities.projects.openFromServer).toBe(false); + expect(capabilities.projects.saveToServer).toBe(false); + }); + + it('should disable sharing', () => { + expect(capabilities.sharing.enabled).toBe(false); + expect(capabilities.sharing.visibility).toBe(false); + expect(capabilities.sharing.links).toBe(false); + }); + + it('should use local-backed file manager', () => { + expect(capabilities.fileManager.enabled).toBe(true); + expect(capabilities.fileManager.serverBacked).toBe(false); + expect(capabilities.fileManager.localBacked).toBe(true); + }); + }); + + describe('electron mode', () => { + const config = new RuntimeConfig({ + mode: 'electron', + baseUrl: 'http://localhost', + wsUrl: null, + staticDataPath: null, + }); + const capabilities = new Capabilities(config); + + it('should disable collaboration (no WebSocket)', () => { + expect(capabilities.collaboration.enabled).toBe(false); + }); + + it('should allow guest access', () => { + expect(capabilities.auth.required).toBe(false); + expect(capabilities.auth.guest).toBe(true); + }); + + it('should use local project storage', () => { + expect(capabilities.projects.localList).toBe(true); + expect(capabilities.projects.remoteList).toBe(false); + }); + + it('should use local-backed file manager', () => { + expect(capabilities.fileManager.localBacked).toBe(true); + expect(capabilities.fileManager.serverBacked).toBe(false); + }); + }); +}); diff --git a/public/app/core/DataProvider.js b/public/app/core/DataProvider.js new file mode 100644 index 000000000..fef584ce0 --- /dev/null +++ b/public/app/core/DataProvider.js @@ -0,0 +1,345 @@ +/** + * DataProvider + * Unified data access abstraction for eXeLearning. + * Switches between server API calls and pre-bundled static data. + * + * Usage: + * const provider = new DataProvider('static'); // or 'server' + * await provider.init(); + * const translations = await provider.getTranslations('en'); + */ + +// Use global AppLogger for debug-controlled logging +const getLogger = () => window.AppLogger || console; + +export default class DataProvider { + /** + * @param {'server' | 'static'} mode - Data access mode + * @param {Object} options - Configuration options + * @param {string} [options.basePath=''] - Base path for API URLs + * @param {Object} [options.staticData=null] - Pre-bundled static data (if not in window.__EXE_STATIC_DATA__) + */ + constructor(mode = 'server', options = {}) { + this.mode = mode; + this.basePath = options.basePath || ''; + this.staticData = options.staticData || null; + this.initialized = false; + + // Cache for loaded data (both modes) + this.cache = { + parameters: null, + translations: {}, + idevices: null, + themes: null, + bundleManifest: null, + }; + + getLogger().log(`[DataProvider] Created in ${mode} mode`); + } + + /** + * Initialize the data provider + * In static mode, loads data from window.__EXE_STATIC_DATA__ or fetches bundle.json + */ + async init() { + if (this.initialized) { + return; + } + + if (this.mode === 'static') { + await this._initStaticData(); + } + + this.initialized = true; + getLogger().log('[DataProvider] Initialized'); + } + + /** + * Load static data from embedded or external source + * @private + */ + async _initStaticData() { + // Priority 1: Constructor-provided data + if (this.staticData) { + getLogger().log('[DataProvider] Using constructor-provided static data'); + return; + } + + // Priority 2: Embedded in window + if (window.__EXE_STATIC_DATA__) { + this.staticData = window.__EXE_STATIC_DATA__; + getLogger().log('[DataProvider] Using window.__EXE_STATIC_DATA__'); + return; + } + + // Priority 3: Fetch from bundle.json + try { + const bundleUrl = `${this.basePath}/data/bundle.json`; + getLogger().log(`[DataProvider] Fetching static data from ${bundleUrl}`); + const response = await fetch(bundleUrl); + if (response.ok) { + this.staticData = await response.json(); + getLogger().log('[DataProvider] Loaded static data from bundle.json'); + return; + } + } catch (e) { + getLogger().warn('[DataProvider] Failed to fetch bundle.json:', e.message); + } + + // Fallback: Create empty structure + getLogger().warn('[DataProvider] No static data source found, using empty defaults'); + this.staticData = { + parameters: { routes: {} }, + translations: { en: { translations: {} } }, + idevices: { idevices: [] }, + themes: { themes: [] }, + bundleManifest: null, + }; + } + + /** + * Check if running in static (offline) mode + * @returns {boolean} + */ + isStaticMode() { + return this.mode === 'static'; + } + + /** + * Check if running in server (online) mode + * @returns {boolean} + */ + isServerMode() { + return this.mode === 'server'; + } + + /** + * Get API parameters (route definitions) + * @returns {Promise<{routes: Object}>} + */ + async getApiParameters() { + if (this.cache.parameters) { + return this.cache.parameters; + } + + if (this.mode === 'static') { + this.cache.parameters = this.staticData?.parameters || { routes: {} }; + return this.cache.parameters; + } + + // Server mode: fetch from API + const url = `${this.basePath}/api/parameter-management/parameters/data/list`; + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + this.cache.parameters = await response.json(); + return this.cache.parameters; + } catch (e) { + getLogger().error('[DataProvider] Failed to fetch API parameters:', e); + throw e; + } + } + + /** + * Get translations for a locale + * @param {string} locale - Language code (e.g., 'en', 'es') + * @returns {Promise<{translations: Object}>} + */ + async getTranslations(locale) { + // Default to 'en' if locale is null/undefined + const safeLocale = locale || 'en'; + + if (this.cache.translations[safeLocale]) { + return this.cache.translations[safeLocale]; + } + + if (this.mode === 'static') { + // Try exact locale, then fall back to base language, then 'en' + const baseLocale = safeLocale.split('-')[0]; + const translations = + this.staticData?.translations?.[safeLocale] || + this.staticData?.translations?.[baseLocale] || + this.staticData?.translations?.en || + { translations: {} }; + + this.cache.translations[safeLocale] = translations; + return translations; + } + + // Server mode: fetch from API + const url = `${this.basePath}/api/translations/${safeLocale}`; + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + this.cache.translations[safeLocale] = await response.json(); + return this.cache.translations[safeLocale]; + } catch (e) { + getLogger().error(`[DataProvider] Failed to fetch translations for ${safeLocale}:`, e); + // Return empty translations to avoid breaking the app + return { translations: {} }; + } + } + + /** + * Get installed iDevices list + * @returns {Promise<{idevices: Array}>} + */ + async getInstalledIdevices() { + if (this.cache.idevices) { + return this.cache.idevices; + } + + if (this.mode === 'static') { + this.cache.idevices = this.staticData?.idevices || { idevices: [] }; + return this.cache.idevices; + } + + // Server mode: will be fetched via apiCallManager + // This method provides a fallback + return { idevices: [] }; + } + + /** + * Get installed themes list + * @returns {Promise<{themes: Array}>} + */ + async getInstalledThemes() { + if (this.cache.themes) { + return this.cache.themes; + } + + if (this.mode === 'static') { + this.cache.themes = this.staticData?.themes || { themes: [] }; + return this.cache.themes; + } + + // Server mode: will be fetched via apiCallManager + // This method provides a fallback + return { themes: [] }; + } + + /** + * Get bundle manifest (for resource fetching) + * @returns {Promise} + */ + async getBundleManifest() { + if (this.cache.bundleManifest !== null) { + return this.cache.bundleManifest; + } + + if (this.mode === 'static') { + this.cache.bundleManifest = this.staticData?.bundleManifest || null; + return this.cache.bundleManifest; + } + + // Server mode: ResourceFetcher handles this + return null; + } + + /** + * Get upload limits configuration + * In static mode, returns sensible defaults (no server-imposed limits) + * @returns {Promise} + */ + async getUploadLimits() { + if (this.mode === 'static') { + return { + maxFileSize: 100 * 1024 * 1024, // 100MB default + maxFileSizeFormatted: '100 MB', + limitingFactor: 'none', + details: { + isStatic: true, + }, + }; + } + + // Server mode: fetch from API + const url = `${this.basePath}/api/config/upload-limits`; + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return await response.json(); + } catch (e) { + getLogger().warn('[DataProvider] Failed to fetch upload limits, using defaults:', e); + return { + maxFileSize: 100 * 1024 * 1024, + maxFileSizeFormatted: '100 MB', + limitingFactor: 'unknown', + }; + } + } + + /** + * Get user preferences + * In static mode, returns default preferences + * @returns {Promise} + */ + async getUserPreferences() { + if (this.mode === 'static') { + return { + locale: navigator.language?.split('-')[0] || 'en', + theme: 'light', + // Add other default preferences as needed + }; + } + + // Server mode: will be fetched via user manager + return {}; + } + + /** + * Clear the cache (useful for testing or mode switching) + */ + clearCache() { + this.cache = { + parameters: null, + translations: {}, + idevices: null, + themes: null, + bundleManifest: null, + }; + } + + /** + * Get all static data (for debugging) + * @returns {Object|null} + */ + getStaticData() { + return this.staticData; + } +} + +// Static helper to detect if static mode should be used +// Prefer using RuntimeConfig.fromEnvironment() or app.capabilities instead +DataProvider.detectMode = function () { + // Prefer capabilities check if app is initialized + const capabilities = window.eXeLearning?.app?.capabilities; + if (capabilities) { + return capabilities.storage.remote ? 'server' : 'static'; + } + + // Fallback to direct detection for early initialization + // Explicit flag takes priority + if (window.__EXE_STATIC_MODE__ === true) { + return 'static'; + } + + // File protocol indicates static mode + if (window.location.protocol === 'file:') { + return 'static'; + } + + // No server URL configured indicates static mode + if (!window.eXeLearning?.config?.fullURL) { + return 'static'; + } + + // Default to server mode + return 'server'; +}; diff --git a/public/app/core/DataProvider.test.js b/public/app/core/DataProvider.test.js new file mode 100644 index 000000000..241f9edb0 --- /dev/null +++ b/public/app/core/DataProvider.test.js @@ -0,0 +1,642 @@ +import DataProvider from './DataProvider.js'; + +describe('DataProvider', () => { + let dataProvider; + + beforeEach(() => { + // Reset window globals + delete window.__EXE_STATIC_MODE__; + delete window.__EXE_STATIC_DATA__; + delete window.eXeLearning; + delete window.AppLogger; + + // Mock fetch + global.fetch = vi.fn(); + + // Mock console for logger + window.AppLogger = { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + delete window.__EXE_STATIC_MODE__; + delete window.__EXE_STATIC_DATA__; + delete window.eXeLearning; + delete window.AppLogger; + }); + + describe('constructor', () => { + it('should default to server mode', () => { + dataProvider = new DataProvider(); + expect(dataProvider.mode).toBe('server'); + expect(dataProvider.isServerMode()).toBe(true); + expect(dataProvider.isStaticMode()).toBe(false); + }); + + it('should accept static mode', () => { + dataProvider = new DataProvider('static'); + expect(dataProvider.mode).toBe('static'); + expect(dataProvider.isStaticMode()).toBe(true); + expect(dataProvider.isServerMode()).toBe(false); + }); + + it('should accept basePath option', () => { + dataProvider = new DataProvider('server', { basePath: '/app' }); + expect(dataProvider.basePath).toBe('/app'); + }); + + it('should accept staticData option', () => { + const staticData = { parameters: { routes: { test: '/test' } } }; + dataProvider = new DataProvider('static', { staticData }); + expect(dataProvider.staticData).toBe(staticData); + }); + + it('should initialize empty cache', () => { + dataProvider = new DataProvider(); + expect(dataProvider.cache.parameters).toBeNull(); + expect(dataProvider.cache.translations).toEqual({}); + expect(dataProvider.cache.idevices).toBeNull(); + expect(dataProvider.cache.themes).toBeNull(); + expect(dataProvider.cache.bundleManifest).toBeNull(); + }); + + it('should not be initialized after construction', () => { + dataProvider = new DataProvider(); + expect(dataProvider.initialized).toBe(false); + }); + }); + + describe('init', () => { + it('should set initialized flag in server mode', async () => { + dataProvider = new DataProvider('server'); + await dataProvider.init(); + expect(dataProvider.initialized).toBe(true); + }); + + it('should not reinitialize if already initialized', async () => { + dataProvider = new DataProvider('static', { + staticData: { parameters: {} }, + }); + await dataProvider.init(); + dataProvider.staticData = { modified: true }; + await dataProvider.init(); + expect(dataProvider.staticData.modified).toBe(true); + }); + + it('should use constructor-provided static data', async () => { + const staticData = { parameters: { routes: { custom: true } } }; + dataProvider = new DataProvider('static', { staticData }); + await dataProvider.init(); + expect(dataProvider.staticData).toBe(staticData); + }); + + it('should use window.__EXE_STATIC_DATA__ if available', async () => { + window.__EXE_STATIC_DATA__ = { parameters: { fromWindow: true } }; + dataProvider = new DataProvider('static'); + await dataProvider.init(); + expect(dataProvider.staticData.parameters.fromWindow).toBe(true); + }); + + it('should fetch bundle.json if no static data provided', async () => { + const bundleData = { parameters: { fromBundle: true } }; + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(bundleData), + }); + + dataProvider = new DataProvider('static', { basePath: '/test' }); + await dataProvider.init(); + + expect(global.fetch).toHaveBeenCalledWith('/test/data/bundle.json'); + expect(dataProvider.staticData.parameters.fromBundle).toBe(true); + }); + + it('should use empty defaults if bundle.json fetch fails', async () => { + global.fetch.mockRejectedValueOnce(new Error('Network error')); + + dataProvider = new DataProvider('static'); + await dataProvider.init(); + + expect(dataProvider.staticData.parameters).toEqual({ routes: {} }); + expect(dataProvider.staticData.translations).toEqual({ en: { translations: {} } }); + expect(dataProvider.staticData.idevices).toEqual({ idevices: [] }); + expect(dataProvider.staticData.themes).toEqual({ themes: [] }); + }); + + it('should use empty defaults if bundle.json returns non-ok response', async () => { + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + dataProvider = new DataProvider('static'); + await dataProvider.init(); + + expect(dataProvider.staticData.parameters).toEqual({ routes: {} }); + }); + }); + + describe('getApiParameters', () => { + it('should return cached parameters if available', async () => { + dataProvider = new DataProvider('server'); + dataProvider.cache.parameters = { routes: { cached: true } }; + + const result = await dataProvider.getApiParameters(); + + expect(result.routes.cached).toBe(true); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('should return static data in static mode', async () => { + dataProvider = new DataProvider('static', { + staticData: { parameters: { routes: { static: true } } }, + }); + await dataProvider.init(); + + const result = await dataProvider.getApiParameters(); + + expect(result.routes.static).toBe(true); + expect(dataProvider.cache.parameters).toEqual({ routes: { static: true } }); + }); + + it('should fetch from API in server mode', async () => { + const apiData = { routes: { api: true } }; + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(apiData), + }); + + dataProvider = new DataProvider('server', { basePath: '/base' }); + const result = await dataProvider.getApiParameters(); + + expect(global.fetch).toHaveBeenCalledWith('/base/api/parameter-management/parameters/data/list'); + expect(result.routes.api).toBe(true); + expect(dataProvider.cache.parameters).toEqual(apiData); + }); + + it('should throw on fetch error in server mode', async () => { + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + dataProvider = new DataProvider('server'); + + await expect(dataProvider.getApiParameters()).rejects.toThrow('HTTP 500'); + }); + + it('should return empty routes if static data is missing', async () => { + dataProvider = new DataProvider('static', { staticData: {} }); + await dataProvider.init(); + + const result = await dataProvider.getApiParameters(); + + expect(result).toEqual({ routes: {} }); + }); + }); + + describe('getTranslations', () => { + it('should return cached translations if available', async () => { + dataProvider = new DataProvider('server'); + dataProvider.cache.translations.en = { translations: { cached: 'Cached' } }; + + const result = await dataProvider.getTranslations('en'); + + expect(result.translations.cached).toBe('Cached'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('should return static translations for exact locale', async () => { + dataProvider = new DataProvider('static', { + staticData: { + translations: { + es: { translations: { hello: 'Hola' } }, + }, + }, + }); + await dataProvider.init(); + + const result = await dataProvider.getTranslations('es'); + + expect(result.translations.hello).toBe('Hola'); + }); + + it('should fallback to base language in static mode', async () => { + dataProvider = new DataProvider('static', { + staticData: { + translations: { + es: { translations: { hello: 'Hola' } }, + }, + }, + }); + await dataProvider.init(); + + const result = await dataProvider.getTranslations('es-MX'); + + expect(result.translations.hello).toBe('Hola'); + }); + + it('should fallback to English in static mode', async () => { + dataProvider = new DataProvider('static', { + staticData: { + translations: { + en: { translations: { hello: 'Hello' } }, + }, + }, + }); + await dataProvider.init(); + + const result = await dataProvider.getTranslations('fr'); + + expect(result.translations.hello).toBe('Hello'); + }); + + it('should return empty translations if no fallback available', async () => { + dataProvider = new DataProvider('static', { + staticData: { translations: {} }, + }); + await dataProvider.init(); + + const result = await dataProvider.getTranslations('fr'); + + expect(result).toEqual({ translations: {} }); + }); + + it('should fetch from API in server mode', async () => { + const apiData = { translations: { api: 'From API' } }; + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(apiData), + }); + + dataProvider = new DataProvider('server', { basePath: '/base' }); + const result = await dataProvider.getTranslations('de'); + + expect(global.fetch).toHaveBeenCalledWith('/base/api/translations/de'); + expect(result.translations.api).toBe('From API'); + expect(dataProvider.cache.translations.de).toEqual(apiData); + }); + + it('should return empty translations on fetch error', async () => { + global.fetch.mockRejectedValueOnce(new Error('Network error')); + + dataProvider = new DataProvider('server'); + const result = await dataProvider.getTranslations('en'); + + expect(result).toEqual({ translations: {} }); + }); + }); + + describe('getInstalledIdevices', () => { + it('should return cached idevices if available', async () => { + dataProvider = new DataProvider('server'); + dataProvider.cache.idevices = { idevices: [{ id: 'cached' }] }; + + const result = await dataProvider.getInstalledIdevices(); + + expect(result.idevices[0].id).toBe('cached'); + }); + + it('should return static idevices in static mode', async () => { + dataProvider = new DataProvider('static', { + staticData: { + idevices: { idevices: [{ id: 'text' }] }, + }, + }); + await dataProvider.init(); + + const result = await dataProvider.getInstalledIdevices(); + + expect(result.idevices[0].id).toBe('text'); + expect(dataProvider.cache.idevices).toEqual({ idevices: [{ id: 'text' }] }); + }); + + it('should return empty list in server mode', async () => { + dataProvider = new DataProvider('server'); + + const result = await dataProvider.getInstalledIdevices(); + + expect(result).toEqual({ idevices: [] }); + }); + + it('should return empty list if static data missing', async () => { + dataProvider = new DataProvider('static', { staticData: {} }); + await dataProvider.init(); + + const result = await dataProvider.getInstalledIdevices(); + + expect(result).toEqual({ idevices: [] }); + }); + }); + + describe('getInstalledThemes', () => { + it('should return cached themes if available', async () => { + dataProvider = new DataProvider('server'); + dataProvider.cache.themes = { themes: [{ id: 'cached' }] }; + + const result = await dataProvider.getInstalledThemes(); + + expect(result.themes[0].id).toBe('cached'); + }); + + it('should return static themes in static mode', async () => { + dataProvider = new DataProvider('static', { + staticData: { + themes: { themes: [{ id: 'base' }] }, + }, + }); + await dataProvider.init(); + + const result = await dataProvider.getInstalledThemes(); + + expect(result.themes[0].id).toBe('base'); + expect(dataProvider.cache.themes).toEqual({ themes: [{ id: 'base' }] }); + }); + + it('should return empty list in server mode', async () => { + dataProvider = new DataProvider('server'); + + const result = await dataProvider.getInstalledThemes(); + + expect(result).toEqual({ themes: [] }); + }); + }); + + describe('getBundleManifest', () => { + it('should return cached manifest', async () => { + dataProvider = new DataProvider('server'); + dataProvider.cache.bundleManifest = { version: '1.0' }; + + const result = await dataProvider.getBundleManifest(); + + expect(result.version).toBe('1.0'); + }); + + it('should return static manifest in static mode', async () => { + dataProvider = new DataProvider('static', { + staticData: { + bundleManifest: { themes: ['base', 'neo'] }, + }, + }); + await dataProvider.init(); + + const result = await dataProvider.getBundleManifest(); + + expect(result.themes).toContain('base'); + }); + + it('should return null in server mode', async () => { + dataProvider = new DataProvider('server'); + + const result = await dataProvider.getBundleManifest(); + + expect(result).toBeNull(); + }); + }); + + describe('getUploadLimits', () => { + it('should return default limits in static mode', async () => { + dataProvider = new DataProvider('static'); + + const result = await dataProvider.getUploadLimits(); + + expect(result.maxFileSize).toBe(100 * 1024 * 1024); + expect(result.maxFileSizeFormatted).toBe('100 MB'); + expect(result.limitingFactor).toBe('none'); + expect(result.details.isStatic).toBe(true); + }); + + it('should fetch limits from API in server mode', async () => { + const apiData = { + maxFileSize: 50 * 1024 * 1024, + maxFileSizeFormatted: '50 MB', + limitingFactor: 'php', + }; + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(apiData), + }); + + dataProvider = new DataProvider('server', { basePath: '/base' }); + const result = await dataProvider.getUploadLimits(); + + expect(global.fetch).toHaveBeenCalledWith('/base/api/config/upload-limits'); + expect(result.maxFileSize).toBe(50 * 1024 * 1024); + }); + + it('should return defaults on fetch error', async () => { + global.fetch.mockRejectedValueOnce(new Error('Network error')); + + dataProvider = new DataProvider('server'); + const result = await dataProvider.getUploadLimits(); + + expect(result.maxFileSize).toBe(100 * 1024 * 1024); + expect(result.limitingFactor).toBe('unknown'); + }); + }); + + describe('getUserPreferences', () => { + it('should return default preferences in static mode', async () => { + // Mock navigator.language + const originalLanguage = navigator.language; + Object.defineProperty(navigator, 'language', { + value: 'es-ES', + configurable: true, + }); + + dataProvider = new DataProvider('static'); + + const result = await dataProvider.getUserPreferences(); + + expect(result.locale).toBe('es'); + expect(result.theme).toBe('light'); + + // Restore + Object.defineProperty(navigator, 'language', { + value: originalLanguage, + configurable: true, + }); + }); + + it('should return empty object in server mode', async () => { + dataProvider = new DataProvider('server'); + + const result = await dataProvider.getUserPreferences(); + + expect(result).toEqual({}); + }); + + it('should handle missing navigator.language', async () => { + const originalLanguage = navigator.language; + Object.defineProperty(navigator, 'language', { + value: undefined, + configurable: true, + }); + + dataProvider = new DataProvider('static'); + const result = await dataProvider.getUserPreferences(); + + expect(result.locale).toBe('en'); + + Object.defineProperty(navigator, 'language', { + value: originalLanguage, + configurable: true, + }); + }); + }); + + describe('clearCache', () => { + it('should reset all cache values', () => { + dataProvider = new DataProvider(); + dataProvider.cache.parameters = { some: 'data' }; + dataProvider.cache.translations = { en: { some: 'trans' } }; + dataProvider.cache.idevices = { idevices: [1, 2] }; + dataProvider.cache.themes = { themes: [1] }; + dataProvider.cache.bundleManifest = { manifest: true }; + + dataProvider.clearCache(); + + expect(dataProvider.cache.parameters).toBeNull(); + expect(dataProvider.cache.translations).toEqual({}); + expect(dataProvider.cache.idevices).toBeNull(); + expect(dataProvider.cache.themes).toBeNull(); + expect(dataProvider.cache.bundleManifest).toBeNull(); + }); + }); + + describe('getStaticData', () => { + it('should return the static data object', () => { + const staticData = { test: true }; + dataProvider = new DataProvider('static', { staticData }); + + expect(dataProvider.getStaticData()).toBe(staticData); + }); + + it('should return null if no static data', () => { + dataProvider = new DataProvider('server'); + + expect(dataProvider.getStaticData()).toBeNull(); + }); + }); + + describe('detectMode (static method)', () => { + beforeEach(() => { + // Reset all mode indicators + delete window.__EXE_STATIC_MODE__; + delete window.eXeLearning; + }); + + it('should return static when __EXE_STATIC_MODE__ is true', () => { + window.__EXE_STATIC_MODE__ = true; + + expect(DataProvider.detectMode()).toBe('static'); + }); + + it('should return static for file:// protocol', () => { + // Mock location.protocol + const originalLocation = window.location; + delete window.location; + window.location = { protocol: 'file:' }; + + expect(DataProvider.detectMode()).toBe('static'); + + window.location = originalLocation; + }); + + it('should return static when no fullURL is configured', () => { + window.eXeLearning = { config: {} }; + + expect(DataProvider.detectMode()).toBe('static'); + }); + + it('should return server when fullURL is configured', () => { + window.eXeLearning = { config: { fullURL: 'http://localhost:8080' } }; + + expect(DataProvider.detectMode()).toBe('server'); + }); + + it('should return server by default', () => { + window.eXeLearning = { config: { fullURL: 'http://example.com' } }; + + expect(DataProvider.detectMode()).toBe('server'); + }); + + it('should prioritize __EXE_STATIC_MODE__ over fullURL', () => { + window.__EXE_STATIC_MODE__ = true; + window.eXeLearning = { config: { fullURL: 'http://localhost:8080' } }; + + expect(DataProvider.detectMode()).toBe('static'); + }); + }); + + describe('integration scenarios', () => { + it('should work correctly in a full static mode flow', async () => { + window.__EXE_STATIC_MODE__ = true; + window.__EXE_STATIC_DATA__ = { + parameters: { routes: { test: '/api/test' } }, + translations: { + en: { translations: { hello: 'Hello' } }, + es: { translations: { hello: 'Hola' } }, + }, + idevices: { idevices: [{ id: 'text', name: 'Free Text' }] }, + themes: { themes: [{ id: 'base', name: 'Base Theme' }] }, + bundleManifest: { version: '3.0' }, + }; + + dataProvider = new DataProvider('static'); + await dataProvider.init(); + + // Test all getters + const params = await dataProvider.getApiParameters(); + expect(params.routes.test).toBe('/api/test'); + + const transEn = await dataProvider.getTranslations('en'); + expect(transEn.translations.hello).toBe('Hello'); + + const transEs = await dataProvider.getTranslations('es'); + expect(transEs.translations.hello).toBe('Hola'); + + const idevices = await dataProvider.getInstalledIdevices(); + expect(idevices.idevices[0].name).toBe('Free Text'); + + const themes = await dataProvider.getInstalledThemes(); + expect(themes.themes[0].name).toBe('Base Theme'); + + const manifest = await dataProvider.getBundleManifest(); + expect(manifest.version).toBe('3.0'); + + const limits = await dataProvider.getUploadLimits(); + expect(limits.details.isStatic).toBe(true); + + // Verify fetch was never called + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('should work correctly in a full server mode flow', async () => { + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ routes: { api: true } }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ translations: { hello: 'Hello' } }), + }); + + dataProvider = new DataProvider('server'); + await dataProvider.init(); + + const params = await dataProvider.getApiParameters(); + expect(params.routes.api).toBe(true); + + const trans = await dataProvider.getTranslations('en'); + expect(trans.translations.hello).toBe('Hello'); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/public/app/core/EmbeddingBridge.js b/public/app/core/EmbeddingBridge.js new file mode 100644 index 000000000..a91e6b8bc --- /dev/null +++ b/public/app/core/EmbeddingBridge.js @@ -0,0 +1,305 @@ +/** + * EmbeddingBridge + * Provides postMessage API for embedding eXeLearning in iframes. + * + * Enables parent windows (WordPress, Moodle, etc.) to: + * - Open project files + * - Request save/export + * - Receive editor status updates + * + * Security: + * - Only accepts messages from trusted origins + * - Validates message structure before processing + * - Responds only to the origin that sent the request + * + * Usage: + * const bridge = new EmbeddingBridge(app); + * bridge.init(); + */ + +// Use global AppLogger for debug-controlled logging +const getLogger = () => window.AppLogger || console; + +export default class EmbeddingBridge { + /** + * @param {App} app - The main eXeLearning App instance + * @param {Object} options - Configuration options + * @param {string[]} [options.trustedOrigins=[]] - List of trusted origins (empty = trust all) + */ + constructor(app, options = {}) { + this.app = app; + this.trustedOrigins = options.trustedOrigins || []; + this.parentOrigin = null; + this.version = window.eXeLearning?.version || 'unknown'; + this.messageHandler = null; + + // Pending requests awaiting response + this.pendingRequests = new Map(); + } + + /** + * Initialize the embedding bridge + * Sets up message listener and announces ready state + */ + init() { + // Only initialize if we're in an iframe + if (window.parent === window) { + getLogger().log('[EmbeddingBridge] Not in iframe, skipping initialization'); + return; + } + + // Setup message handler + this.messageHandler = this.handleMessage.bind(this); + window.addEventListener('message', this.messageHandler); + + // Announce ready state to parent (use '*' since we don't know origin yet) + window.parent.postMessage({ + type: 'EXELEARNING_READY', + version: this.version, + capabilities: this.getCapabilities(), + }, '*'); + + getLogger().log('[EmbeddingBridge] Initialized, announced ready to parent'); + } + + /** + * Cleanup resources + */ + destroy() { + if (this.messageHandler) { + window.removeEventListener('message', this.messageHandler); + this.messageHandler = null; + } + this.pendingRequests.clear(); + } + + /** + * Get supported capabilities + * @returns {string[]} + */ + getCapabilities() { + return [ + 'OPEN_FILE', // Open .elpx file from bytes + 'REQUEST_SAVE', // Get current project as bytes + 'GET_PROJECT_INFO', // Get project metadata + ]; + } + + /** + * Handle incoming postMessage + * @param {MessageEvent} event + */ + async handleMessage(event) { + // Security: Check origin if trusted origins are configured + if (this.trustedOrigins.length > 0) { + if (!this.trustedOrigins.includes(event.origin)) { + getLogger().warn('[EmbeddingBridge] Rejected message from untrusted origin:', event.origin); + return; + } + } + + // Validate message structure + const { type, data, requestId } = event.data || {}; + if (!type) { + return; // Ignore messages without type + } + + // Store parent origin for responses + this.parentOrigin = event.origin; + + getLogger().log(`[EmbeddingBridge] Received message: ${type}`); + + try { + switch (type) { + case 'SET_TRUSTED_ORIGINS': + this.handleSetTrustedOrigins(data, requestId); + break; + + case 'OPEN_FILE': + await this.handleOpenFile(data, requestId); + break; + + case 'REQUEST_SAVE': + await this.handleSaveRequest(requestId); + break; + + case 'GET_PROJECT_INFO': + await this.handleGetProjectInfo(requestId); + break; + + default: + // Unknown message type, ignore + break; + } + } catch (error) { + getLogger().error(`[EmbeddingBridge] Error handling ${type}:`, error); + this.postToParent({ + type: `${type}_ERROR`, + requestId, + error: error.message || 'Unknown error', + }); + } + } + + /** + * Handle SET_TRUSTED_ORIGINS message + * @param {Object} data + * @param {string} requestId + */ + handleSetTrustedOrigins(data, requestId) { + if (data?.origins && Array.isArray(data.origins)) { + this.trustedOrigins = data.origins; + getLogger().log('[EmbeddingBridge] Trusted origins updated:', this.trustedOrigins); + } + + this.postToParent({ + type: 'SET_TRUSTED_ORIGINS_SUCCESS', + requestId, + }); + } + + /** + * Handle OPEN_FILE message + * Opens a project from provided file bytes + * @param {Object} data - { bytes: ArrayBuffer, filename: string } + * @param {string} requestId + */ + async handleOpenFile(data, requestId) { + if (!data?.bytes) { + throw new Error('Missing file bytes'); + } + + const filename = data.filename || 'project.elpx'; + const file = new File([data.bytes], filename, { type: 'application/zip' }); + + // Generate project UUID + const uuid = crypto.randomUUID(); + window.eXeLearning.projectId = uuid; + + // Import the file + const project = this.app.project; + if (project && typeof project.importElpxFile === 'function') { + await project.importElpxFile(file); + } else if (project?._yjsBridge?.importer) { + await project._yjsBridge.importer.importFromFile(file); + } else { + throw new Error('Project import not available'); + } + + this.postToParent({ + type: 'OPEN_FILE_SUCCESS', + requestId, + projectId: uuid, + }); + } + + /** + * Handle REQUEST_SAVE message + * Exports current project and sends bytes to parent + * @param {string} requestId + */ + async handleSaveRequest(requestId) { + const project = this.app.project; + + // Try to export using available methods + let blob; + let filename; + + if (project && typeof project.exportToElpxBlob === 'function') { + blob = await project.exportToElpxBlob(); + filename = project.getExportFilename?.() || 'project.elpx'; + } else if (project?._yjsBridge?.exporter) { + blob = await project._yjsBridge.exporter.exportToBlob(); + filename = project._yjsBridge.exporter.buildFilename?.() || 'project.elpx'; + } else { + throw new Error('Export not available'); + } + + // Convert blob to ArrayBuffer + const bytes = await blob.arrayBuffer(); + + this.postToParent({ + type: 'SAVE_FILE', + requestId, + bytes, + filename, + size: bytes.byteLength, + }); + } + + /** + * Handle GET_PROJECT_INFO message + * Returns metadata about the current project + * @param {string} requestId + */ + async handleGetProjectInfo(requestId) { + const project = this.app.project; + const documentManager = project?._yjsBridge?.documentManager; + + if (!documentManager) { + throw new Error('No project loaded'); + } + + const metadata = documentManager.getMetadata(); + const navigation = documentManager.getNavigation(); + + this.postToParent({ + type: 'PROJECT_INFO', + requestId, + projectId: project._yjsBridge.projectId, + title: metadata?.get('title') || 'Untitled', + author: metadata?.get('author') || '', + description: metadata?.get('description') || '', + language: metadata?.get('language') || 'en', + theme: metadata?.get('theme') || 'base', + pageCount: navigation?.length || 0, + modifiedAt: metadata?.get('modifiedAt'), + }); + } + + /** + * Post message to parent window + * @param {Object} message - Message to send + */ + postToParent(message) { + if (window.parent !== window && this.parentOrigin) { + try { + window.parent.postMessage(message, this.parentOrigin); + } catch (e) { + // If origin validation fails, try with '*' for same-origin iframes + if (e.name === 'DataCloneError') { + getLogger().error('[EmbeddingBridge] Cannot serialize message:', e); + } else { + window.parent.postMessage(message, '*'); + } + } + } + } + + /** + * Notify parent of project state change + * @param {string} event - Event name + * @param {Object} data - Event data + */ + notifyParent(event, data = {}) { + this.postToParent({ + type: 'EXELEARNING_EVENT', + event, + data, + }); + } + + /** + * Notify parent that the project has been modified + */ + notifyDirty() { + this.notifyParent('PROJECT_DIRTY', { isDirty: true }); + } + + /** + * Notify parent that the project was saved + */ + notifySaved() { + this.notifyParent('PROJECT_SAVED', { isDirty: false }); + } +} diff --git a/public/app/core/EmbeddingBridge.test.js b/public/app/core/EmbeddingBridge.test.js new file mode 100644 index 000000000..3618f53c0 --- /dev/null +++ b/public/app/core/EmbeddingBridge.test.js @@ -0,0 +1,795 @@ +import EmbeddingBridge from './EmbeddingBridge.js'; + +describe('EmbeddingBridge', () => { + let bridge; + let mockApp; + let originalParent; + let originalAddEventListener; + let originalRemoveEventListener; + let messageHandler; + + beforeEach(() => { + // Store original window properties + originalParent = window.parent; + originalAddEventListener = window.addEventListener; + originalRemoveEventListener = window.removeEventListener; + + // Mock window.addEventListener to capture the message handler + messageHandler = null; + window.addEventListener = vi.fn((event, handler) => { + if (event === 'message') { + messageHandler = handler; + } + }); + window.removeEventListener = vi.fn(); + + // Mock window.parent to simulate iframe context + Object.defineProperty(window, 'parent', { + value: { + postMessage: vi.fn(), + }, + writable: true, + configurable: true, + }); + + // Mock eXeLearning global + window.eXeLearning = { + version: '3.0.0', + projectId: null, + }; + + // Mock crypto.randomUUID + if (!global.crypto) { + global.crypto = {}; + } + global.crypto.randomUUID = vi.fn(() => 'test-uuid-1234'); + + // Mock AppLogger + window.AppLogger = { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + // Create mock app + mockApp = { + project: { + importElpxFile: vi.fn().mockResolvedValue(undefined), + exportToElpxBlob: vi.fn().mockResolvedValue(new Blob(['test'])), + getExportFilename: vi.fn(() => 'test-project.elpx'), + _yjsBridge: { + projectId: 'project-123', + importer: { + importFromFile: vi.fn().mockResolvedValue(undefined), + }, + exporter: { + exportToBlob: vi.fn().mockResolvedValue(new Blob(['test'])), + buildFilename: vi.fn(() => 'exported.elpx'), + }, + documentManager: { + getMetadata: vi.fn(() => new Map([ + ['title', 'Test Project'], + ['author', 'Test Author'], + ['description', 'Test Description'], + ['language', 'en'], + ['theme', 'base'], + ['modifiedAt', '2024-01-01T00:00:00Z'], + ])), + getNavigation: vi.fn(() => ({ length: 3 })), + }, + }, + }, + }; + + bridge = new EmbeddingBridge(mockApp); + }); + + afterEach(() => { + vi.clearAllMocks(); + + // Restore original window properties + Object.defineProperty(window, 'parent', { + value: originalParent, + writable: true, + configurable: true, + }); + window.addEventListener = originalAddEventListener; + window.removeEventListener = originalRemoveEventListener; + + delete window.eXeLearning; + delete window.AppLogger; + }); + + describe('constructor', () => { + it('should initialize with default options', () => { + expect(bridge.app).toBe(mockApp); + expect(bridge.trustedOrigins).toEqual([]); + expect(bridge.parentOrigin).toBeNull(); + expect(bridge.version).toBe('3.0.0'); + expect(bridge.messageHandler).toBeNull(); + expect(bridge.pendingRequests).toBeInstanceOf(Map); + }); + + it('should accept trusted origins option', () => { + const bridgeWithOrigins = new EmbeddingBridge(mockApp, { + trustedOrigins: ['https://example.com', 'https://test.com'], + }); + expect(bridgeWithOrigins.trustedOrigins).toEqual(['https://example.com', 'https://test.com']); + }); + + it('should use "unknown" version if not available', () => { + delete window.eXeLearning.version; + const bridgeNoVersion = new EmbeddingBridge(mockApp); + expect(bridgeNoVersion.version).toBe('unknown'); + }); + }); + + describe('init', () => { + it('should skip initialization if not in iframe', () => { + // Make window.parent === window (not in iframe) + Object.defineProperty(window, 'parent', { + value: window, + writable: true, + configurable: true, + }); + + bridge.init(); + + expect(window.addEventListener).not.toHaveBeenCalled(); + expect(bridge.messageHandler).toBeNull(); + }); + + it('should set up message handler when in iframe', () => { + bridge.init(); + + expect(window.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + expect(bridge.messageHandler).not.toBeNull(); + }); + + it('should announce ready state to parent', () => { + bridge.init(); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'EXELEARNING_READY', + version: '3.0.0', + capabilities: expect.arrayContaining(['OPEN_FILE', 'REQUEST_SAVE', 'GET_PROJECT_INFO']), + }), + '*' + ); + }); + }); + + describe('destroy', () => { + it('should remove message listener', () => { + bridge.init(); + const handler = bridge.messageHandler; + + bridge.destroy(); + + expect(window.removeEventListener).toHaveBeenCalledWith('message', handler); + expect(bridge.messageHandler).toBeNull(); + }); + + it('should clear pending requests', () => { + bridge.pendingRequests.set('req-1', { resolve: vi.fn() }); + bridge.pendingRequests.set('req-2', { resolve: vi.fn() }); + + bridge.destroy(); + + expect(bridge.pendingRequests.size).toBe(0); + }); + + it('should do nothing if not initialized', () => { + bridge.destroy(); + + expect(window.removeEventListener).not.toHaveBeenCalled(); + }); + }); + + describe('getCapabilities', () => { + it('should return supported capabilities', () => { + const capabilities = bridge.getCapabilities(); + + expect(capabilities).toContain('OPEN_FILE'); + expect(capabilities).toContain('REQUEST_SAVE'); + expect(capabilities).toContain('GET_PROJECT_INFO'); + }); + }); + + describe('handleMessage', () => { + beforeEach(() => { + bridge.init(); + }); + + it('should reject messages from untrusted origins when trusted origins are set', async () => { + bridge.trustedOrigins = ['https://trusted.com']; + + await messageHandler({ + origin: 'https://untrusted.com', + data: { type: 'OPEN_FILE' }, + }); + + expect(window.AppLogger.warn).toHaveBeenCalledWith( + '[EmbeddingBridge] Rejected message from untrusted origin:', + 'https://untrusted.com' + ); + }); + + it('should accept messages from trusted origins', async () => { + bridge.trustedOrigins = ['https://trusted.com']; + + await messageHandler({ + origin: 'https://trusted.com', + data: { type: 'GET_PROJECT_INFO', requestId: 'req-1' }, + }); + + expect(bridge.parentOrigin).toBe('https://trusted.com'); + }); + + it('should accept all origins when trustedOrigins is empty', async () => { + bridge.trustedOrigins = []; + + await messageHandler({ + origin: 'https://any-origin.com', + data: { type: 'GET_PROJECT_INFO', requestId: 'req-1' }, + }); + + expect(bridge.parentOrigin).toBe('https://any-origin.com'); + }); + + it('should ignore messages without type', async () => { + const initialCallCount = window.parent.postMessage.mock.calls.length; + + await messageHandler({ + origin: 'https://example.com', + data: { foo: 'bar' }, + }); + + // Should not have sent any additional messages beyond the READY message + expect(window.parent.postMessage).toHaveBeenCalledTimes(initialCallCount); + }); + + it('should ignore messages with null data', async () => { + const initialCallCount = window.parent.postMessage.mock.calls.length; + + await messageHandler({ + origin: 'https://example.com', + data: null, + }); + + // Should not have sent any additional messages beyond the READY message + expect(window.parent.postMessage).toHaveBeenCalledTimes(initialCallCount); + }); + + it('should store parent origin for responses', async () => { + await messageHandler({ + origin: 'https://parent.com', + data: { type: 'GET_PROJECT_INFO', requestId: 'req-1' }, + }); + + expect(bridge.parentOrigin).toBe('https://parent.com'); + }); + + it('should send error response on exception', async () => { + mockApp.project._yjsBridge.documentManager = null; + + await messageHandler({ + origin: 'https://example.com', + data: { type: 'GET_PROJECT_INFO', requestId: 'req-error' }, + }); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'GET_PROJECT_INFO_ERROR', + requestId: 'req-error', + error: expect.any(String), + }), + 'https://example.com' + ); + }); + }); + + describe('handleSetTrustedOrigins', () => { + beforeEach(() => { + bridge.init(); + bridge.parentOrigin = 'https://parent.com'; + }); + + it('should update trusted origins', async () => { + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'SET_TRUSTED_ORIGINS', + data: { origins: ['https://new-trusted.com'] }, + requestId: 'req-origins', + }, + }); + + expect(bridge.trustedOrigins).toEqual(['https://new-trusted.com']); + }); + + it('should send success response', async () => { + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'SET_TRUSTED_ORIGINS', + data: { origins: ['https://new-trusted.com'] }, + requestId: 'req-origins', + }, + }); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'SET_TRUSTED_ORIGINS_SUCCESS', + requestId: 'req-origins', + }), + 'https://parent.com' + ); + }); + + it('should not update if origins is not an array', async () => { + bridge.trustedOrigins = ['https://original.com']; + + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'SET_TRUSTED_ORIGINS', + data: { origins: 'invalid' }, + requestId: 'req-invalid', + }, + }); + + expect(bridge.trustedOrigins).toEqual(['https://original.com']); + }); + }); + + describe('handleOpenFile', () => { + beforeEach(() => { + bridge.init(); + bridge.parentOrigin = 'https://parent.com'; + }); + + it('should throw error if bytes not provided', async () => { + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'OPEN_FILE', + data: {}, + requestId: 'req-open', + }, + }); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'OPEN_FILE_ERROR', + requestId: 'req-open', + error: 'Missing file bytes', + }), + 'https://parent.com' + ); + }); + + it('should import file using project.importElpxFile', async () => { + const bytes = new ArrayBuffer(8); + + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'OPEN_FILE', + data: { bytes, filename: 'test.elpx' }, + requestId: 'req-open', + }, + }); + + expect(mockApp.project.importElpxFile).toHaveBeenCalledWith( + expect.any(File) + ); + expect(window.eXeLearning.projectId).toBe('test-uuid-1234'); + }); + + it('should send success response with project ID', async () => { + const bytes = new ArrayBuffer(8); + + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'OPEN_FILE', + data: { bytes, filename: 'test.elpx' }, + requestId: 'req-open-success', + }, + }); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'OPEN_FILE_SUCCESS', + requestId: 'req-open-success', + projectId: 'test-uuid-1234', + }), + 'https://parent.com' + ); + }); + + it('should use default filename if not provided', async () => { + const bytes = new ArrayBuffer(8); + + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'OPEN_FILE', + data: { bytes }, + requestId: 'req-open', + }, + }); + + expect(mockApp.project.importElpxFile).toHaveBeenCalledWith( + expect.objectContaining({ name: 'project.elpx' }) + ); + }); + + it('should fallback to _yjsBridge.importer if importElpxFile not available', async () => { + mockApp.project.importElpxFile = undefined; + const bytes = new ArrayBuffer(8); + + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'OPEN_FILE', + data: { bytes, filename: 'test.elpx' }, + requestId: 'req-open', + }, + }); + + expect(mockApp.project._yjsBridge.importer.importFromFile).toHaveBeenCalled(); + }); + + it('should throw error if no import method available', async () => { + mockApp.project.importElpxFile = undefined; + mockApp.project._yjsBridge.importer = undefined; + const bytes = new ArrayBuffer(8); + + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'OPEN_FILE', + data: { bytes, filename: 'test.elpx' }, + requestId: 'req-open', + }, + }); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'OPEN_FILE_ERROR', + error: 'Project import not available', + }), + 'https://parent.com' + ); + }); + }); + + describe('handleSaveRequest', () => { + beforeEach(() => { + bridge.init(); + bridge.parentOrigin = 'https://parent.com'; + }); + + it('should export project using exportToElpxBlob', async () => { + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'REQUEST_SAVE', + requestId: 'req-save', + }, + }); + + expect(mockApp.project.exportToElpxBlob).toHaveBeenCalled(); + }); + + it('should send SAVE_FILE response with bytes', async () => { + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'REQUEST_SAVE', + requestId: 'req-save', + }, + }); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'SAVE_FILE', + requestId: 'req-save', + bytes: expect.any(ArrayBuffer), + filename: 'test-project.elpx', + size: expect.any(Number), + }), + 'https://parent.com' + ); + }); + + it('should fallback to _yjsBridge.exporter if exportToElpxBlob not available', async () => { + mockApp.project.exportToElpxBlob = undefined; + + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'REQUEST_SAVE', + requestId: 'req-save', + }, + }); + + expect(mockApp.project._yjsBridge.exporter.exportToBlob).toHaveBeenCalled(); + }); + + it('should use fallback filename if getExportFilename not available', async () => { + mockApp.project.getExportFilename = undefined; + + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'REQUEST_SAVE', + requestId: 'req-save', + }, + }); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + filename: 'project.elpx', + }), + 'https://parent.com' + ); + }); + + it('should throw error if no export method available', async () => { + mockApp.project.exportToElpxBlob = undefined; + mockApp.project._yjsBridge.exporter = undefined; + + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'REQUEST_SAVE', + requestId: 'req-save', + }, + }); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'REQUEST_SAVE_ERROR', + error: 'Export not available', + }), + 'https://parent.com' + ); + }); + }); + + describe('handleGetProjectInfo', () => { + beforeEach(() => { + bridge.init(); + bridge.parentOrigin = 'https://parent.com'; + }); + + it('should return project metadata', async () => { + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'GET_PROJECT_INFO', + requestId: 'req-info', + }, + }); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'PROJECT_INFO', + requestId: 'req-info', + projectId: 'project-123', + title: 'Test Project', + author: 'Test Author', + description: 'Test Description', + language: 'en', + theme: 'base', + pageCount: 3, + modifiedAt: '2024-01-01T00:00:00Z', + }), + 'https://parent.com' + ); + }); + + it('should throw error if documentManager not available', async () => { + mockApp.project._yjsBridge.documentManager = null; + + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'GET_PROJECT_INFO', + requestId: 'req-info', + }, + }); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'GET_PROJECT_INFO_ERROR', + error: 'No project loaded', + }), + 'https://parent.com' + ); + }); + + it('should use default values for missing metadata', async () => { + mockApp.project._yjsBridge.documentManager.getMetadata = vi.fn(() => new Map()); + mockApp.project._yjsBridge.documentManager.getNavigation = vi.fn(() => null); + + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'GET_PROJECT_INFO', + requestId: 'req-info', + }, + }); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Untitled', + author: '', + description: '', + language: 'en', + theme: 'base', + pageCount: 0, + }), + 'https://parent.com' + ); + }); + }); + + describe('postToParent', () => { + it('should not post if not in iframe', () => { + // Create a mock postMessage on window (since window.parent === window) + const mockPostMessage = vi.fn(); + window.postMessage = mockPostMessage; + + Object.defineProperty(window, 'parent', { + value: window, + writable: true, + configurable: true, + }); + + bridge.parentOrigin = 'https://parent.com'; + bridge.postToParent({ type: 'TEST' }); + + // postToParent should not call postMessage when not in iframe + expect(mockPostMessage).not.toHaveBeenCalled(); + }); + + it('should not post if parentOrigin is not set', () => { + bridge.parentOrigin = null; + bridge.postToParent({ type: 'TEST' }); + + expect(window.parent.postMessage).not.toHaveBeenCalled(); + }); + + it('should post message to parent with correct origin', () => { + bridge.parentOrigin = 'https://parent.com'; + bridge.postToParent({ type: 'TEST', data: 'value' }); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + { type: 'TEST', data: 'value' }, + 'https://parent.com' + ); + }); + + it('should fallback to * on non-DataCloneError', () => { + bridge.parentOrigin = 'https://parent.com'; + window.parent.postMessage = vi.fn() + .mockImplementationOnce(() => { + throw new Error('Some other error'); + }) + .mockImplementationOnce(() => {}); + + bridge.postToParent({ type: 'TEST' }); + + expect(window.parent.postMessage).toHaveBeenCalledTimes(2); + expect(window.parent.postMessage).toHaveBeenLastCalledWith( + { type: 'TEST' }, + '*' + ); + }); + + it('should log error on DataCloneError', () => { + bridge.parentOrigin = 'https://parent.com'; + const dataCloneError = new Error('Cannot clone'); + dataCloneError.name = 'DataCloneError'; + window.parent.postMessage = vi.fn().mockImplementation(() => { + throw dataCloneError; + }); + + bridge.postToParent({ type: 'TEST' }); + + expect(window.AppLogger.error).toHaveBeenCalledWith( + '[EmbeddingBridge] Cannot serialize message:', + dataCloneError + ); + }); + }); + + describe('notifyParent', () => { + it('should post EXELEARNING_EVENT message', () => { + bridge.parentOrigin = 'https://parent.com'; + bridge.notifyParent('SOME_EVENT', { key: 'value' }); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + { + type: 'EXELEARNING_EVENT', + event: 'SOME_EVENT', + data: { key: 'value' }, + }, + 'https://parent.com' + ); + }); + + it('should use empty data object by default', () => { + bridge.parentOrigin = 'https://parent.com'; + bridge.notifyParent('SIMPLE_EVENT'); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + { + type: 'EXELEARNING_EVENT', + event: 'SIMPLE_EVENT', + data: {}, + }, + 'https://parent.com' + ); + }); + }); + + describe('notifyDirty', () => { + it('should notify parent with PROJECT_DIRTY event', () => { + bridge.parentOrigin = 'https://parent.com'; + bridge.notifyDirty(); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + { + type: 'EXELEARNING_EVENT', + event: 'PROJECT_DIRTY', + data: { isDirty: true }, + }, + 'https://parent.com' + ); + }); + }); + + describe('notifySaved', () => { + it('should notify parent with PROJECT_SAVED event', () => { + bridge.parentOrigin = 'https://parent.com'; + bridge.notifySaved(); + + expect(window.parent.postMessage).toHaveBeenCalledWith( + { + type: 'EXELEARNING_EVENT', + event: 'PROJECT_SAVED', + data: { isDirty: false }, + }, + 'https://parent.com' + ); + }); + }); + + describe('unknown message types', () => { + beforeEach(() => { + bridge.init(); + bridge.parentOrigin = 'https://parent.com'; + }); + + it('should ignore unknown message types', async () => { + const initialCallCount = window.parent.postMessage.mock.calls.length; + + await messageHandler({ + origin: 'https://parent.com', + data: { + type: 'UNKNOWN_TYPE', + requestId: 'req-unknown', + }, + }); + + // Should not have sent any additional messages + expect(window.parent.postMessage).toHaveBeenCalledTimes(initialCallCount); + }); + }); +}); diff --git a/public/app/core/HttpClient.js b/public/app/core/HttpClient.js new file mode 100644 index 000000000..2c48c322d --- /dev/null +++ b/public/app/core/HttpClient.js @@ -0,0 +1,233 @@ +/** + * HttpClient - HTTP abstraction for server communication. + * Used by server adapters to make API calls. + */ +import { NetworkError, AuthError } from './errors.js'; + +export class HttpClient { + /** + * @param {string} baseUrl - Base URL for all requests + */ + constructor(baseUrl) { + this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash + } + + /** + * Make a GET request. + * @param {string} path - API path + * @param {Object} [options] - Fetch options + * @returns {Promise} + */ + async get(path, options = {}) { + return this._request('GET', path, null, options); + } + + /** + * Make a POST request. + * @param {string} path - API path + * @param {Object|FormData} data - Request body + * @param {Object} [options] - Fetch options + * @returns {Promise} + */ + async post(path, data, options = {}) { + return this._request('POST', path, data, options); + } + + /** + * Make a PUT request. + * @param {string} path - API path + * @param {Object} data - Request body + * @param {Object} [options] - Fetch options + * @returns {Promise} + */ + async put(path, data, options = {}) { + return this._request('PUT', path, data, options); + } + + /** + * Make a PATCH request. + * @param {string} path - API path + * @param {Object} data - Request body + * @param {Object} [options] - Fetch options + * @returns {Promise} + */ + async patch(path, data, options = {}) { + return this._request('PATCH', path, data, options); + } + + /** + * Make a DELETE request. + * @param {string} path - API path + * @param {Object} [options] - Fetch options + * @returns {Promise} + */ + async delete(path, options = {}) { + return this._request('DELETE', path, null, options); + } + + /** + * Upload a file. + * @param {string} path - API path + * @param {FormData} formData - Form data with file + * @param {Function} [onProgress] - Progress callback + * @returns {Promise} + */ + async upload(path, formData, onProgress = null) { + const url = this._buildUrl(path); + + // Use XMLHttpRequest for progress support + if (onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', url); + xhr.withCredentials = true; + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + onProgress(e.loaded / e.total); + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText)); + } catch { + resolve(xhr.responseText); + } + } else { + reject( + new NetworkError( + `Upload failed: ${xhr.statusText}`, + xhr.status + ) + ); + } + }); + + xhr.addEventListener('error', () => { + reject(new NetworkError('Upload failed: Network error')); + }); + + xhr.send(formData); + }); + } + + // Use fetch for simple uploads + return this._request('POST', path, formData, { isFormData: true }); + } + + /** + * Download a file as Blob. + * @param {string} path - API path + * @returns {Promise} + */ + async downloadBlob(path) { + const url = this._buildUrl(path); + const response = await fetch(url, { + method: 'GET', + credentials: 'include', + }); + + if (!response.ok) { + throw new NetworkError( + `Download failed: ${response.statusText}`, + response.status + ); + } + + return response.blob(); + } + + /** + * Internal request method. + * @private + */ + async _request(method, path, data, options = {}) { + const url = this._buildUrl(path); + const headers = {}; + + let body = null; + if (data) { + if (options.isFormData || data instanceof FormData) { + body = data; + // Don't set Content-Type for FormData (browser sets it with boundary) + } else { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(data); + } + } + + const fetchOptions = { + method, + headers, + credentials: 'include', // Include cookies for session + body, + ...options, + }; + + // Remove our custom options from fetch + delete fetchOptions.isFormData; + + try { + const response = await fetch(url, fetchOptions); + + // Handle authentication errors + if (response.status === 401) { + throw new AuthError('Session expired', true); + } + + if (response.status === 403) { + throw new AuthError('Access denied'); + } + + if (!response.ok) { + let errorData = null; + try { + errorData = await response.json(); + } catch { + // Ignore JSON parse errors + } + throw new NetworkError( + errorData?.message || + `Request failed: ${response.statusText}`, + response.status, + errorData + ); + } + + // Return empty for 204 No Content + if (response.status === 204) { + return null; + } + + // Try to parse JSON, fall back to text + const contentType = response.headers.get('content-type'); + if (contentType?.includes('application/json')) { + return response.json(); + } + + return response.text(); + } catch (error) { + if (error instanceof NetworkError || error instanceof AuthError) { + throw error; + } + // Network failure (no response) + throw new NetworkError(`Network error: ${error.message}`); + } + } + + /** + * Build full URL from path. + * @private + */ + _buildUrl(path) { + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + const cleanPath = path.startsWith('/') ? path : `/${path}`; + return `${this.baseUrl}${cleanPath}`; + } +} + +export default HttpClient; diff --git a/public/app/core/ProviderFactory.js b/public/app/core/ProviderFactory.js new file mode 100644 index 000000000..61f799af6 --- /dev/null +++ b/public/app/core/ProviderFactory.js @@ -0,0 +1,376 @@ +/** + * ProviderFactory - Base class for creating adapters. + * This is the single decision point for mode-based adapter creation. + */ +import { RuntimeConfig } from './RuntimeConfig.js'; +import { Capabilities } from './Capabilities.js'; +import { HttpClient } from './HttpClient.js'; + +// Server adapters +import { ServerProjectRepository } from './adapters/server/ServerProjectRepository.js'; +import { ServerCatalogAdapter } from './adapters/server/ServerCatalogAdapter.js'; +import { ServerAssetAdapter } from './adapters/server/ServerAssetAdapter.js'; +import { ServerCollaborationAdapter } from './adapters/server/ServerCollaborationAdapter.js'; +import { ServerExportAdapter } from './adapters/server/ServerExportAdapter.js'; +import { ServerContentAdapter } from './adapters/server/ServerContentAdapter.js'; +import { ServerUserPreferenceAdapter } from './adapters/server/ServerUserPreferenceAdapter.js'; +import { ServerLinkValidationAdapter } from './adapters/server/ServerLinkValidationAdapter.js'; +import { ServerCloudStorageAdapter } from './adapters/server/ServerCloudStorageAdapter.js'; +import { ServerPlatformIntegrationAdapter } from './adapters/server/ServerPlatformIntegrationAdapter.js'; +import { ServerSharingAdapter } from './adapters/server/ServerSharingAdapter.js'; + +// Static adapters +import { StaticProjectRepository } from './adapters/static/StaticProjectRepository.js'; +import { StaticCatalogAdapter } from './adapters/static/StaticCatalogAdapter.js'; +import { StaticAssetAdapter } from './adapters/static/StaticAssetAdapter.js'; +import { NullCollaborationAdapter } from './adapters/static/NullCollaborationAdapter.js'; +import { StaticExportAdapter } from './adapters/static/StaticExportAdapter.js'; +import { StaticContentAdapter } from './adapters/static/StaticContentAdapter.js'; +import { StaticUserPreferenceAdapter } from './adapters/static/StaticUserPreferenceAdapter.js'; +import { StaticLinkValidationAdapter } from './adapters/static/StaticLinkValidationAdapter.js'; +import { StaticCloudStorageAdapter } from './adapters/static/StaticCloudStorageAdapter.js'; +import { StaticPlatformIntegrationAdapter } from './adapters/static/StaticPlatformIntegrationAdapter.js'; +import { StaticSharingAdapter } from './adapters/static/StaticSharingAdapter.js'; + +/** + * ProviderFactory - Creates adapters based on runtime mode. + * Use ProviderFactory.create() to get the appropriate factory. + */ +export class ProviderFactory { + /** + * @param {RuntimeConfig} config + * @param {Capabilities} capabilities + */ + constructor(config, capabilities) { + this.config = config; + this.capabilities = capabilities; + } + + /** + * Create the appropriate ProviderFactory based on environment. + * This is the ONLY place that mode detection happens. + * @returns {Promise} + */ + static async create() { + const config = RuntimeConfig.fromEnvironment(); + const capabilities = new Capabilities(config); + + if (config.isStaticMode() || config.isElectronMode()) { + // Load static bundle data + let bundleData = {}; + try { + const response = await fetch(config.staticDataPath || './data/bundle.json'); + if (response.ok) { + bundleData = await response.json(); + } + } catch (error) { + console.warn('[ProviderFactory] Failed to load bundle data:', error); + } + + return new StaticProviderFactory(config, capabilities, bundleData); + } + + const factory = new ServerProviderFactory(config, capabilities); + // Load API endpoints before returning (needed for adapters to know endpoint URLs) + await factory.loadEndpoints(); + return factory; + } + + /** + * Get the runtime configuration. + * @returns {RuntimeConfig} + */ + getConfig() { + return this.config; + } + + /** + * Get the capabilities. + * @returns {Capabilities} + */ + getCapabilities() { + return this.capabilities; + } + + // Abstract factory methods - to be implemented by subclasses + createProjectRepository() { + throw new Error('ProviderFactory.createProjectRepository() not implemented'); + } + + createCatalogAdapter() { + throw new Error('ProviderFactory.createCatalogAdapter() not implemented'); + } + + createAssetAdapter() { + throw new Error('ProviderFactory.createAssetAdapter() not implemented'); + } + + createCollaborationAdapter() { + throw new Error('ProviderFactory.createCollaborationAdapter() not implemented'); + } + + createExportAdapter() { + throw new Error('ProviderFactory.createExportAdapter() not implemented'); + } + + createContentAdapter() { + throw new Error('ProviderFactory.createContentAdapter() not implemented'); + } + + createUserPreferencesAdapter() { + throw new Error('ProviderFactory.createUserPreferencesAdapter() not implemented'); + } + + createLinkValidationAdapter() { + throw new Error('ProviderFactory.createLinkValidationAdapter() not implemented'); + } + + createCloudStorageAdapter() { + throw new Error('ProviderFactory.createCloudStorageAdapter() not implemented'); + } + + createPlatformIntegrationAdapter() { + throw new Error('ProviderFactory.createPlatformIntegrationAdapter() not implemented'); + } + + createSharingAdapter() { + throw new Error('ProviderFactory.createSharingAdapter() not implemented'); + } + + /** + * Create all adapters at once for injection. + * @returns {Object} All adapters keyed by name + */ + createAllAdapters() { + return { + projectRepo: this.createProjectRepository(), + catalog: this.createCatalogAdapter(), + assets: this.createAssetAdapter(), + collaboration: this.createCollaborationAdapter(), + exportAdapter: this.createExportAdapter(), + content: this.createContentAdapter(), + userPreferences: this.createUserPreferencesAdapter(), + linkValidation: this.createLinkValidationAdapter(), + cloudStorage: this.createCloudStorageAdapter(), + platformIntegration: this.createPlatformIntegrationAdapter(), + sharing: this.createSharingAdapter(), + }; + } +} + +/** + * ServerProviderFactory - Creates server-mode adapters. + */ +export class ServerProviderFactory extends ProviderFactory { + /** + * @param {RuntimeConfig} config + * @param {Capabilities} capabilities + */ + constructor(config, capabilities) { + super(config, capabilities); + this.httpClient = new HttpClient(config.baseUrl); + this.basePath = window.eXeLearning?.config?.basePath || ''; + this._endpoints = null; + } + + /** + * Load API endpoints. + * @returns {Promise} + */ + async loadEndpoints() { + if (this._endpoints) { + return this._endpoints; + } + + try { + const url = `${this.basePath}/api/parameter-management/parameters/data/list`; + const params = await this.httpClient.get(url); + this._endpoints = {}; + for (const [key, data] of Object.entries(params.routes || {})) { + this._endpoints[key] = { + path: this.config.baseUrl + data.path, + methods: data.methods, + }; + } + } catch (error) { + console.warn('[ServerProviderFactory] Failed to load endpoints:', error); + this._endpoints = {}; + } + + return this._endpoints; + } + + /** + * @inheritdoc + */ + createProjectRepository() { + return new ServerProjectRepository(this.httpClient, this.basePath); + } + + /** + * @inheritdoc + */ + createCatalogAdapter() { + return new ServerCatalogAdapter(this.httpClient, this._endpoints || {}); + } + + /** + * @inheritdoc + */ + createAssetAdapter() { + return new ServerAssetAdapter(this.httpClient, this.basePath); + } + + /** + * @inheritdoc + */ + createCollaborationAdapter() { + return new ServerCollaborationAdapter(this.config.wsUrl, this.basePath); + } + + /** + * @inheritdoc + */ + createExportAdapter() { + return new ServerExportAdapter(this.httpClient, this._endpoints || {}, this.basePath); + } + + /** + * @inheritdoc + */ + createContentAdapter() { + return new ServerContentAdapter(this.httpClient, this._endpoints || {}); + } + + /** + * @inheritdoc + */ + createUserPreferencesAdapter() { + return new ServerUserPreferenceAdapter(this.httpClient, this._endpoints || {}, this.basePath); + } + + /** + * @inheritdoc + */ + createLinkValidationAdapter() { + return new ServerLinkValidationAdapter(this.httpClient, this._endpoints || {}, this.basePath); + } + + /** + * @inheritdoc + */ + createCloudStorageAdapter() { + return new ServerCloudStorageAdapter(this.httpClient, this._endpoints || {}); + } + + /** + * @inheritdoc + */ + createPlatformIntegrationAdapter() { + return new ServerPlatformIntegrationAdapter(this.httpClient, this._endpoints || {}); + } + + /** + * @inheritdoc + */ + createSharingAdapter() { + return new ServerSharingAdapter(this.httpClient, this._endpoints || {}, this.basePath); + } +} + +/** + * StaticProviderFactory - Creates static/offline-mode adapters. + */ +export class StaticProviderFactory extends ProviderFactory { + /** + * @param {RuntimeConfig} config + * @param {Capabilities} capabilities + * @param {Object} bundleData - Pre-loaded bundle data from bundle.json + */ + constructor(config, capabilities, bundleData = {}) { + super(config, capabilities); + this.bundleData = bundleData; + } + + /** + * @inheritdoc + */ + createProjectRepository() { + return new StaticProjectRepository(); + } + + /** + * @inheritdoc + */ + createCatalogAdapter() { + // Pass the existing DataProvider if available for backwards compatibility + const dataProvider = window.eXeLearning?.app?.dataProvider || null; + return new StaticCatalogAdapter(this.bundleData, dataProvider); + } + + /** + * @inheritdoc + */ + createAssetAdapter() { + return new StaticAssetAdapter(); + } + + /** + * @inheritdoc + */ + createCollaborationAdapter() { + return new NullCollaborationAdapter(); + } + + /** + * @inheritdoc + */ + createExportAdapter() { + return new StaticExportAdapter(); + } + + /** + * @inheritdoc + */ + createContentAdapter() { + const dataProvider = window.eXeLearning?.app?.dataProvider || null; + return new StaticContentAdapter(dataProvider); + } + + /** + * @inheritdoc + */ + createUserPreferencesAdapter() { + return new StaticUserPreferenceAdapter(); + } + + /** + * @inheritdoc + */ + createLinkValidationAdapter() { + return new StaticLinkValidationAdapter(); + } + + /** + * @inheritdoc + */ + createCloudStorageAdapter() { + return new StaticCloudStorageAdapter(); + } + + /** + * @inheritdoc + */ + createPlatformIntegrationAdapter() { + return new StaticPlatformIntegrationAdapter(); + } + + /** + * @inheritdoc + */ + createSharingAdapter() { + return new StaticSharingAdapter(); + } +} + +export default ProviderFactory; diff --git a/public/app/core/ProviderFactory.test.js b/public/app/core/ProviderFactory.test.js new file mode 100644 index 000000000..bdce672a5 --- /dev/null +++ b/public/app/core/ProviderFactory.test.js @@ -0,0 +1,656 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + ProviderFactory, + ServerProviderFactory, + StaticProviderFactory, +} from './ProviderFactory.js'; + +// Create class-based mocks for server adapters +vi.mock('./adapters/server/ServerProjectRepository.js', () => ({ + ServerProjectRepository: class { constructor() { this.type = 'ServerProjectRepository'; } }, +})); +vi.mock('./adapters/server/ServerCatalogAdapter.js', () => ({ + ServerCatalogAdapter: class { constructor() { this.type = 'ServerCatalogAdapter'; } }, +})); +vi.mock('./adapters/server/ServerAssetAdapter.js', () => ({ + ServerAssetAdapter: class { constructor() { this.type = 'ServerAssetAdapter'; } }, +})); +vi.mock('./adapters/server/ServerCollaborationAdapter.js', () => ({ + ServerCollaborationAdapter: class { constructor() { this.type = 'ServerCollaborationAdapter'; } }, +})); +vi.mock('./adapters/server/ServerExportAdapter.js', () => ({ + ServerExportAdapter: class { constructor() { this.type = 'ServerExportAdapter'; } }, +})); +vi.mock('./adapters/server/ServerContentAdapter.js', () => ({ + ServerContentAdapter: class { constructor() { this.type = 'ServerContentAdapter'; } }, +})); +vi.mock('./adapters/server/ServerUserPreferenceAdapter.js', () => ({ + ServerUserPreferenceAdapter: class { constructor() { this.type = 'ServerUserPreferenceAdapter'; } }, +})); +vi.mock('./adapters/server/ServerLinkValidationAdapter.js', () => ({ + ServerLinkValidationAdapter: class { constructor() { this.type = 'ServerLinkValidationAdapter'; } }, +})); +vi.mock('./adapters/server/ServerCloudStorageAdapter.js', () => ({ + ServerCloudStorageAdapter: class { constructor() { this.type = 'ServerCloudStorageAdapter'; } }, +})); +vi.mock('./adapters/server/ServerPlatformIntegrationAdapter.js', () => ({ + ServerPlatformIntegrationAdapter: class { constructor() { this.type = 'ServerPlatformIntegrationAdapter'; } }, +})); +vi.mock('./adapters/server/ServerSharingAdapter.js', () => ({ + ServerSharingAdapter: class { constructor() { this.type = 'ServerSharingAdapter'; } }, +})); + +// Create class-based mocks for static adapters +vi.mock('./adapters/static/StaticProjectRepository.js', () => ({ + StaticProjectRepository: class { constructor() { this.type = 'StaticProjectRepository'; } }, +})); +vi.mock('./adapters/static/StaticCatalogAdapter.js', () => ({ + StaticCatalogAdapter: class { constructor() { this.type = 'StaticCatalogAdapter'; } }, +})); +vi.mock('./adapters/static/StaticAssetAdapter.js', () => ({ + StaticAssetAdapter: class { constructor() { this.type = 'StaticAssetAdapter'; } }, +})); +vi.mock('./adapters/static/NullCollaborationAdapter.js', () => ({ + NullCollaborationAdapter: class { constructor() { this.type = 'NullCollaborationAdapter'; } }, +})); +vi.mock('./adapters/static/StaticExportAdapter.js', () => ({ + StaticExportAdapter: class { constructor() { this.type = 'StaticExportAdapter'; } }, +})); +vi.mock('./adapters/static/StaticContentAdapter.js', () => ({ + StaticContentAdapter: class { constructor() { this.type = 'StaticContentAdapter'; } }, +})); +vi.mock('./adapters/static/StaticUserPreferenceAdapter.js', () => ({ + StaticUserPreferenceAdapter: class { constructor() { this.type = 'StaticUserPreferenceAdapter'; } }, +})); +vi.mock('./adapters/static/StaticLinkValidationAdapter.js', () => ({ + StaticLinkValidationAdapter: class { constructor() { this.type = 'StaticLinkValidationAdapter'; } }, +})); +vi.mock('./adapters/static/StaticCloudStorageAdapter.js', () => ({ + StaticCloudStorageAdapter: class { constructor() { this.type = 'StaticCloudStorageAdapter'; } }, +})); +vi.mock('./adapters/static/StaticPlatformIntegrationAdapter.js', () => ({ + StaticPlatformIntegrationAdapter: class { constructor() { this.type = 'StaticPlatformIntegrationAdapter'; } }, +})); +vi.mock('./adapters/static/StaticSharingAdapter.js', () => ({ + StaticSharingAdapter: class { constructor() { this.type = 'StaticSharingAdapter'; } }, +})); + +describe('ProviderFactory', () => { + let mockConfig; + let mockCapabilities; + + beforeEach(() => { + mockConfig = { + mode: 'server', + baseUrl: 'http://localhost:8083', + wsUrl: 'ws://localhost:8083', + staticDataPath: null, + isStaticMode: vi.fn().mockReturnValue(false), + isElectronMode: vi.fn().mockReturnValue(false), + }; + mockCapabilities = { + collaboration: { enabled: true }, + storage: { remote: true }, + }; + }); + + describe('constructor', () => { + it('should store config and capabilities', () => { + const factory = new ProviderFactory(mockConfig, mockCapabilities); + expect(factory.config).toBe(mockConfig); + expect(factory.capabilities).toBe(mockCapabilities); + }); + }); + + describe('getConfig', () => { + it('should return the config', () => { + const factory = new ProviderFactory(mockConfig, mockCapabilities); + expect(factory.getConfig()).toBe(mockConfig); + }); + }); + + describe('getCapabilities', () => { + it('should return the capabilities', () => { + const factory = new ProviderFactory(mockConfig, mockCapabilities); + expect(factory.getCapabilities()).toBe(mockCapabilities); + }); + }); + + describe('abstract methods', () => { + it('should throw for createProjectRepository', () => { + const factory = new ProviderFactory(mockConfig, mockCapabilities); + expect(() => factory.createProjectRepository()).toThrow('not implemented'); + }); + + it('should throw for createCatalogAdapter', () => { + const factory = new ProviderFactory(mockConfig, mockCapabilities); + expect(() => factory.createCatalogAdapter()).toThrow('not implemented'); + }); + + it('should throw for createAssetAdapter', () => { + const factory = new ProviderFactory(mockConfig, mockCapabilities); + expect(() => factory.createAssetAdapter()).toThrow('not implemented'); + }); + + it('should throw for createCollaborationAdapter', () => { + const factory = new ProviderFactory(mockConfig, mockCapabilities); + expect(() => factory.createCollaborationAdapter()).toThrow('not implemented'); + }); + + it('should throw for createExportAdapter', () => { + const factory = new ProviderFactory(mockConfig, mockCapabilities); + expect(() => factory.createExportAdapter()).toThrow('not implemented'); + }); + + it('should throw for createContentAdapter', () => { + const factory = new ProviderFactory(mockConfig, mockCapabilities); + expect(() => factory.createContentAdapter()).toThrow('not implemented'); + }); + + it('should throw for createUserPreferencesAdapter', () => { + const factory = new ProviderFactory(mockConfig, mockCapabilities); + expect(() => factory.createUserPreferencesAdapter()).toThrow('not implemented'); + }); + + it('should throw for createLinkValidationAdapter', () => { + const factory = new ProviderFactory(mockConfig, mockCapabilities); + expect(() => factory.createLinkValidationAdapter()).toThrow('not implemented'); + }); + + it('should throw for createCloudStorageAdapter', () => { + const factory = new ProviderFactory(mockConfig, mockCapabilities); + expect(() => factory.createCloudStorageAdapter()).toThrow('not implemented'); + }); + + it('should throw for createPlatformIntegrationAdapter', () => { + const factory = new ProviderFactory(mockConfig, mockCapabilities); + expect(() => factory.createPlatformIntegrationAdapter()).toThrow('not implemented'); + }); + + it('should throw for createSharingAdapter', () => { + const factory = new ProviderFactory(mockConfig, mockCapabilities); + expect(() => factory.createSharingAdapter()).toThrow('not implemented'); + }); + }); +}); + +describe('ServerProviderFactory', () => { + let mockConfig; + let mockCapabilities; + let mockHttpClient; + let factory; + + beforeEach(() => { + // Setup window.eXeLearning + window.eXeLearning = { + config: { basePath: '/test' }, + }; + + mockConfig = { + mode: 'server', + baseUrl: 'http://localhost:8083', + wsUrl: 'ws://localhost:8083', + staticDataPath: null, + isStaticMode: vi.fn().mockReturnValue(false), + isElectronMode: vi.fn().mockReturnValue(false), + }; + mockCapabilities = { + collaboration: { enabled: true }, + storage: { remote: true }, + }; + + factory = new ServerProviderFactory(mockConfig, mockCapabilities); + + // Mock the httpClient + mockHttpClient = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }; + factory.httpClient = mockHttpClient; + }); + + afterEach(() => { + delete window.eXeLearning; + }); + + describe('constructor', () => { + it('should create httpClient', () => { + expect(factory.httpClient).toBeDefined(); + }); + + it('should set basePath from window.eXeLearning', () => { + expect(factory.basePath).toBe('/test'); + }); + + it('should default basePath to empty string', () => { + delete window.eXeLearning; + const newFactory = new ServerProviderFactory(mockConfig, mockCapabilities); + expect(newFactory.basePath).toBe(''); + }); + + it('should initialize endpoints as null', () => { + expect(factory._endpoints).toBeNull(); + }); + }); + + describe('loadEndpoints', () => { + it('should fetch and parse endpoints', async () => { + mockHttpClient.get.mockResolvedValue({ + routes: { + api_test: { path: '/api/test', methods: ['GET'] }, + api_other: { path: '/api/other', methods: ['POST'] }, + }, + }); + + const endpoints = await factory.loadEndpoints(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/test/api/parameter-management/parameters/data/list'); + expect(endpoints.api_test).toEqual({ + path: 'http://localhost:8083/api/test', + methods: ['GET'], + }); + expect(endpoints.api_other).toEqual({ + path: 'http://localhost:8083/api/other', + methods: ['POST'], + }); + }); + + it('should return cached endpoints on subsequent calls', async () => { + mockHttpClient.get.mockResolvedValue({ routes: {} }); + + await factory.loadEndpoints(); + await factory.loadEndpoints(); + + expect(mockHttpClient.get).toHaveBeenCalledTimes(1); + }); + + it('should handle fetch error gracefully', async () => { + mockHttpClient.get.mockRejectedValue(new Error('Network error')); + + const endpoints = await factory.loadEndpoints(); + + expect(endpoints).toEqual({}); + }); + + it('should handle missing routes gracefully', async () => { + mockHttpClient.get.mockResolvedValue({}); + + const endpoints = await factory.loadEndpoints(); + + expect(endpoints).toEqual({}); + }); + }); + + describe('createProjectRepository', () => { + it('should create ServerProjectRepository', () => { + const repo = factory.createProjectRepository(); + expect(repo.type).toBe('ServerProjectRepository'); + }); + }); + + describe('createCatalogAdapter', () => { + it('should create ServerCatalogAdapter', () => { + const adapter = factory.createCatalogAdapter(); + expect(adapter.type).toBe('ServerCatalogAdapter'); + }); + }); + + describe('createAssetAdapter', () => { + it('should create ServerAssetAdapter', () => { + const adapter = factory.createAssetAdapter(); + expect(adapter.type).toBe('ServerAssetAdapter'); + }); + }); + + describe('createCollaborationAdapter', () => { + it('should create ServerCollaborationAdapter', () => { + const adapter = factory.createCollaborationAdapter(); + expect(adapter.type).toBe('ServerCollaborationAdapter'); + }); + }); + + describe('createExportAdapter', () => { + it('should create ServerExportAdapter', () => { + const adapter = factory.createExportAdapter(); + expect(adapter.type).toBe('ServerExportAdapter'); + }); + }); + + describe('createContentAdapter', () => { + it('should create ServerContentAdapter', () => { + const adapter = factory.createContentAdapter(); + expect(adapter.type).toBe('ServerContentAdapter'); + }); + }); + + describe('createUserPreferencesAdapter', () => { + it('should create ServerUserPreferenceAdapter', () => { + const adapter = factory.createUserPreferencesAdapter(); + expect(adapter.type).toBe('ServerUserPreferenceAdapter'); + }); + }); + + describe('createLinkValidationAdapter', () => { + it('should create ServerLinkValidationAdapter', () => { + const adapter = factory.createLinkValidationAdapter(); + expect(adapter.type).toBe('ServerLinkValidationAdapter'); + }); + }); + + describe('createCloudStorageAdapter', () => { + it('should create ServerCloudStorageAdapter', () => { + const adapter = factory.createCloudStorageAdapter(); + expect(adapter.type).toBe('ServerCloudStorageAdapter'); + }); + }); + + describe('createPlatformIntegrationAdapter', () => { + it('should create ServerPlatformIntegrationAdapter', () => { + const adapter = factory.createPlatformIntegrationAdapter(); + expect(adapter.type).toBe('ServerPlatformIntegrationAdapter'); + }); + }); + + describe('createSharingAdapter', () => { + it('should create ServerSharingAdapter', () => { + const adapter = factory.createSharingAdapter(); + expect(adapter.type).toBe('ServerSharingAdapter'); + }); + }); + + describe('createAllAdapters', () => { + it('should create all adapters at once', () => { + const adapters = factory.createAllAdapters(); + + expect(adapters.projectRepo.type).toBe('ServerProjectRepository'); + expect(adapters.catalog.type).toBe('ServerCatalogAdapter'); + expect(adapters.assets.type).toBe('ServerAssetAdapter'); + expect(adapters.collaboration.type).toBe('ServerCollaborationAdapter'); + expect(adapters.exportAdapter.type).toBe('ServerExportAdapter'); + expect(adapters.content.type).toBe('ServerContentAdapter'); + expect(adapters.userPreferences.type).toBe('ServerUserPreferenceAdapter'); + expect(adapters.linkValidation.type).toBe('ServerLinkValidationAdapter'); + expect(adapters.cloudStorage.type).toBe('ServerCloudStorageAdapter'); + expect(adapters.platformIntegration.type).toBe('ServerPlatformIntegrationAdapter'); + expect(adapters.sharing.type).toBe('ServerSharingAdapter'); + }); + + it('should return 11 adapters', () => { + const adapters = factory.createAllAdapters(); + expect(Object.keys(adapters)).toHaveLength(11); + }); + }); +}); + +describe('StaticProviderFactory', () => { + let mockConfig; + let mockCapabilities; + let mockBundleData; + let factory; + + beforeEach(() => { + // Setup window.eXeLearning + window.eXeLearning = { + app: { + dataProvider: { type: 'mockDataProvider' }, + }, + }; + + mockConfig = { + mode: 'static', + baseUrl: '.', + wsUrl: null, + staticDataPath: './data/bundle.json', + isStaticMode: vi.fn().mockReturnValue(true), + isElectronMode: vi.fn().mockReturnValue(false), + }; + mockCapabilities = { + collaboration: { enabled: false }, + storage: { remote: false, local: true }, + }; + mockBundleData = { + idevices: [{ id: 'text' }], + themes: [{ id: 'base' }], + locales: [{ code: 'en' }], + }; + + factory = new StaticProviderFactory(mockConfig, mockCapabilities, mockBundleData); + }); + + afterEach(() => { + delete window.eXeLearning; + }); + + describe('constructor', () => { + it('should store bundleData', () => { + expect(factory.bundleData).toBe(mockBundleData); + }); + + it('should default bundleData to empty object', () => { + const newFactory = new StaticProviderFactory(mockConfig, mockCapabilities); + expect(newFactory.bundleData).toEqual({}); + }); + }); + + describe('createProjectRepository', () => { + it('should create StaticProjectRepository', () => { + const repo = factory.createProjectRepository(); + expect(repo.type).toBe('StaticProjectRepository'); + }); + }); + + describe('createCatalogAdapter', () => { + it('should create StaticCatalogAdapter', () => { + const adapter = factory.createCatalogAdapter(); + expect(adapter.type).toBe('StaticCatalogAdapter'); + }); + }); + + describe('createAssetAdapter', () => { + it('should create StaticAssetAdapter', () => { + const adapter = factory.createAssetAdapter(); + expect(adapter.type).toBe('StaticAssetAdapter'); + }); + }); + + describe('createCollaborationAdapter', () => { + it('should create NullCollaborationAdapter', () => { + const adapter = factory.createCollaborationAdapter(); + expect(adapter.type).toBe('NullCollaborationAdapter'); + }); + }); + + describe('createExportAdapter', () => { + it('should create StaticExportAdapter', () => { + const adapter = factory.createExportAdapter(); + expect(adapter.type).toBe('StaticExportAdapter'); + }); + }); + + describe('createContentAdapter', () => { + it('should create StaticContentAdapter', () => { + const adapter = factory.createContentAdapter(); + expect(adapter.type).toBe('StaticContentAdapter'); + }); + }); + + describe('createUserPreferencesAdapter', () => { + it('should create StaticUserPreferenceAdapter', () => { + const adapter = factory.createUserPreferencesAdapter(); + expect(adapter.type).toBe('StaticUserPreferenceAdapter'); + }); + }); + + describe('createLinkValidationAdapter', () => { + it('should create StaticLinkValidationAdapter', () => { + const adapter = factory.createLinkValidationAdapter(); + expect(adapter.type).toBe('StaticLinkValidationAdapter'); + }); + }); + + describe('createCloudStorageAdapter', () => { + it('should create StaticCloudStorageAdapter', () => { + const adapter = factory.createCloudStorageAdapter(); + expect(adapter.type).toBe('StaticCloudStorageAdapter'); + }); + }); + + describe('createPlatformIntegrationAdapter', () => { + it('should create StaticPlatformIntegrationAdapter', () => { + const adapter = factory.createPlatformIntegrationAdapter(); + expect(adapter.type).toBe('StaticPlatformIntegrationAdapter'); + }); + }); + + describe('createSharingAdapter', () => { + it('should create StaticSharingAdapter', () => { + const adapter = factory.createSharingAdapter(); + expect(adapter.type).toBe('StaticSharingAdapter'); + }); + }); + + describe('createAllAdapters', () => { + it('should create all adapters at once', () => { + const adapters = factory.createAllAdapters(); + + expect(adapters.projectRepo.type).toBe('StaticProjectRepository'); + expect(adapters.catalog.type).toBe('StaticCatalogAdapter'); + expect(adapters.assets.type).toBe('StaticAssetAdapter'); + expect(adapters.collaboration.type).toBe('NullCollaborationAdapter'); + expect(adapters.exportAdapter.type).toBe('StaticExportAdapter'); + expect(adapters.content.type).toBe('StaticContentAdapter'); + expect(adapters.userPreferences.type).toBe('StaticUserPreferenceAdapter'); + expect(adapters.linkValidation.type).toBe('StaticLinkValidationAdapter'); + expect(adapters.cloudStorage.type).toBe('StaticCloudStorageAdapter'); + expect(adapters.platformIntegration.type).toBe('StaticPlatformIntegrationAdapter'); + expect(adapters.sharing.type).toBe('StaticSharingAdapter'); + }); + + it('should return 11 adapters', () => { + const adapters = factory.createAllAdapters(); + expect(Object.keys(adapters)).toHaveLength(11); + }); + }); +}); + +describe('ProviderFactory.create()', () => { + let originalFetch; + let originalExeStatic; + + beforeEach(() => { + originalFetch = global.fetch; + originalExeStatic = window.__EXE_STATIC_MODE__; + + // Setup window.eXeLearning + window.eXeLearning = { + config: { basePath: '' }, + }; + }); + + afterEach(() => { + global.fetch = originalFetch; + window.__EXE_STATIC_MODE__ = originalExeStatic; + delete window.eXeLearning; + }); + + it('should create ServerProviderFactory in server mode', async () => { + delete window.__EXE_STATIC_MODE__; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ routes: {} }), + }); + + const factory = await ProviderFactory.create(); + + expect(factory).toBeInstanceOf(ServerProviderFactory); + }); + + it('should load endpoints for ServerProviderFactory', async () => { + delete window.__EXE_STATIC_MODE__; + + const mockEndpoints = { + routes: { api_test: { path: '/api/test', methods: ['GET'] } }, + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockEndpoints), + }); + + const factory = await ProviderFactory.create(); + + expect(factory._endpoints).not.toBeNull(); + }); + + it('should create StaticProviderFactory in static mode', async () => { + window.__EXE_STATIC_MODE__ = true; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ idevices: [], themes: [] }), + }); + + const factory = await ProviderFactory.create(); + + expect(factory).toBeInstanceOf(StaticProviderFactory); + }); + + it('should load bundle data for StaticProviderFactory', async () => { + window.__EXE_STATIC_MODE__ = true; + + const mockBundleData = { + idevices: [{ id: 'text' }], + themes: [{ id: 'base' }], + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockBundleData), + }); + + const factory = await ProviderFactory.create(); + + expect(factory.bundleData).toEqual(mockBundleData); + }); + + it('should handle bundle data fetch error gracefully', async () => { + window.__EXE_STATIC_MODE__ = true; + + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + + const factory = await ProviderFactory.create(); + + expect(factory).toBeInstanceOf(StaticProviderFactory); + expect(factory.bundleData).toEqual({}); + }); + + it('should handle non-ok response for bundle data', async () => { + window.__EXE_STATIC_MODE__ = true; + + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + }); + + const factory = await ProviderFactory.create(); + + expect(factory.bundleData).toEqual({}); + }); + + it('should set capabilities from config', async () => { + delete window.__EXE_STATIC_MODE__; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ routes: {} }), + }); + + const factory = await ProviderFactory.create(); + + expect(factory.capabilities).toBeDefined(); + expect(factory.capabilities.collaboration).toBeDefined(); + }); +}); diff --git a/public/app/core/RuntimeConfig.js b/public/app/core/RuntimeConfig.js new file mode 100644 index 000000000..d964666b9 --- /dev/null +++ b/public/app/core/RuntimeConfig.js @@ -0,0 +1,84 @@ +/** + * RuntimeConfig - Immutable bootstrap configuration. + * This is the ONLY place that checks window.__EXE_STATIC_MODE__. + * All other code should use capabilities or injected adapters. + */ +export class RuntimeConfig { + /** + * @param {Object} options + * @param {'server'|'static'|'electron'} options.mode - Runtime mode + * @param {string} options.baseUrl - Base URL for API calls + * @param {string|null} options.wsUrl - WebSocket URL (null in static mode) + * @param {string|null} options.staticDataPath - Path to bundle.json (null in server mode) + */ + constructor(options) { + this.mode = options.mode; + this.baseUrl = options.baseUrl; + this.wsUrl = options.wsUrl; + this.staticDataPath = options.staticDataPath; + Object.freeze(this); + } + + /** + * Create RuntimeConfig from environment detection. + * This is the single decision point for mode detection. + * @returns {RuntimeConfig} + */ + static fromEnvironment() { + // Check for static mode flag (set by build-static-bundle.ts) + if (window.__EXE_STATIC_MODE__) { + return new RuntimeConfig({ + mode: 'static', + baseUrl: '.', + wsUrl: null, + staticDataPath: './data/bundle.json', + }); + } + + // Check for Electron mode + if (window.electronAPI) { + return new RuntimeConfig({ + mode: 'electron', + baseUrl: window.location.origin, + wsUrl: null, // Electron doesn't use WebSocket collaboration + staticDataPath: null, + }); + } + + // Default: server mode + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return new RuntimeConfig({ + mode: 'server', + baseUrl: window.location.origin, + wsUrl: `${protocol}//${window.location.host}`, + staticDataPath: null, + }); + } + + /** + * Check if running in static mode (no server). + * Prefer using capabilities instead of this method. + * @returns {boolean} + */ + isStaticMode() { + return this.mode === 'static'; + } + + /** + * Check if running in server mode (full API available). + * @returns {boolean} + */ + isServerMode() { + return this.mode === 'server'; + } + + /** + * Check if running in Electron mode. + * @returns {boolean} + */ + isElectronMode() { + return this.mode === 'electron'; + } +} + +export default RuntimeConfig; diff --git a/public/app/core/RuntimeConfig.test.js b/public/app/core/RuntimeConfig.test.js new file mode 100644 index 000000000..9acf69761 --- /dev/null +++ b/public/app/core/RuntimeConfig.test.js @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { RuntimeConfig } from './RuntimeConfig.js'; + +describe('RuntimeConfig', () => { + let originalStaticMode; + let originalElectronAPI; + + beforeEach(() => { + originalStaticMode = window.__EXE_STATIC_MODE__; + originalElectronAPI = window.electronAPI; + }); + + afterEach(() => { + window.__EXE_STATIC_MODE__ = originalStaticMode; + window.electronAPI = originalElectronAPI; + }); + + describe('constructor', () => { + it('should create immutable config object', () => { + const config = new RuntimeConfig({ + mode: 'server', + baseUrl: 'http://localhost:8080', + wsUrl: 'ws://localhost:8080', + staticDataPath: null, + }); + + expect(Object.isFrozen(config)).toBe(true); + expect(() => { + config.mode = 'static'; + }).toThrow(); + }); + + it('should store all provided options', () => { + const config = new RuntimeConfig({ + mode: 'static', + baseUrl: '.', + wsUrl: null, + staticDataPath: './data/bundle.json', + }); + + expect(config.mode).toBe('static'); + expect(config.baseUrl).toBe('.'); + expect(config.wsUrl).toBe(null); + expect(config.staticDataPath).toBe('./data/bundle.json'); + }); + }); + + describe('fromEnvironment', () => { + it('should detect static mode', () => { + window.__EXE_STATIC_MODE__ = true; + delete window.electronAPI; + + const config = RuntimeConfig.fromEnvironment(); + + expect(config.mode).toBe('static'); + expect(config.baseUrl).toBe('.'); + expect(config.wsUrl).toBe(null); + expect(config.staticDataPath).toBe('./data/bundle.json'); + }); + + it('should detect Electron mode', () => { + delete window.__EXE_STATIC_MODE__; + window.electronAPI = { test: true }; + + const config = RuntimeConfig.fromEnvironment(); + + expect(config.mode).toBe('electron'); + expect(config.wsUrl).toBe(null); + }); + + it('should default to server mode', () => { + delete window.__EXE_STATIC_MODE__; + delete window.electronAPI; + + const config = RuntimeConfig.fromEnvironment(); + + expect(config.mode).toBe('server'); + expect(config.wsUrl).not.toBe(null); + expect(config.staticDataPath).toBe(null); + }); + }); + + describe('isStaticMode', () => { + it('should return true for static mode', () => { + const config = new RuntimeConfig({ mode: 'static', baseUrl: '.', wsUrl: null, staticDataPath: null }); + expect(config.isStaticMode()).toBe(true); + }); + + it('should return false for server mode', () => { + const config = new RuntimeConfig({ mode: 'server', baseUrl: 'http://localhost', wsUrl: 'ws://localhost', staticDataPath: null }); + expect(config.isStaticMode()).toBe(false); + }); + }); + + describe('isServerMode', () => { + it('should return true for server mode', () => { + const config = new RuntimeConfig({ mode: 'server', baseUrl: 'http://localhost', wsUrl: 'ws://localhost', staticDataPath: null }); + expect(config.isServerMode()).toBe(true); + }); + + it('should return false for static mode', () => { + const config = new RuntimeConfig({ mode: 'static', baseUrl: '.', wsUrl: null, staticDataPath: null }); + expect(config.isServerMode()).toBe(false); + }); + }); + + describe('isElectronMode', () => { + it('should return true for Electron mode', () => { + const config = new RuntimeConfig({ mode: 'electron', baseUrl: 'http://localhost', wsUrl: null, staticDataPath: null }); + expect(config.isElectronMode()).toBe(true); + }); + + it('should return false for other modes', () => { + const serverConfig = new RuntimeConfig({ mode: 'server', baseUrl: 'http://localhost', wsUrl: 'ws://localhost', staticDataPath: null }); + const staticConfig = new RuntimeConfig({ mode: 'static', baseUrl: '.', wsUrl: null, staticDataPath: null }); + + expect(serverConfig.isElectronMode()).toBe(false); + expect(staticConfig.isElectronMode()).toBe(false); + }); + }); +}); diff --git a/public/app/core/adapters/server/ServerAssetAdapter.js b/public/app/core/adapters/server/ServerAssetAdapter.js new file mode 100644 index 000000000..4744a28b3 --- /dev/null +++ b/public/app/core/adapters/server/ServerAssetAdapter.js @@ -0,0 +1,161 @@ +/** + * ServerAssetAdapter - Server-side implementation of AssetPort. + * Handles asset upload/download via HTTP API. + */ +import { AssetPort } from '../../ports/AssetPort.js'; +import { NetworkError } from '../../errors.js'; + +export class ServerAssetAdapter extends AssetPort { + /** + * @param {import('../../HttpClient').HttpClient} httpClient + * @param {string} basePath - API base path + */ + constructor(httpClient, basePath = '') { + super(); + this.http = httpClient; + this.basePath = basePath; + } + + /** + * @inheritdoc + */ + async upload(projectId, file, path) { + const formData = new FormData(); + formData.append('file', file); + formData.append('path', path); + + const url = `${this.basePath}/api/assets/${projectId}/upload`; + return this.http.upload(url, formData); + } + + /** + * @inheritdoc + */ + async getUrl(projectId, path) { + // Return the URL that can be used to access the asset + return `${this.http.baseUrl}${this.basePath}/api/assets/${projectId}/${encodeURIComponent(path)}`; + } + + /** + * @inheritdoc + */ + async getBlob(projectId, path) { + const url = `${this.basePath}/api/assets/${projectId}/${encodeURIComponent(path)}`; + return this.http.downloadBlob(url); + } + + /** + * @inheritdoc + */ + async delete(projectId, path) { + const url = `${this.basePath}/api/assets/${projectId}/${encodeURIComponent(path)}`; + return this.http.delete(url); + } + + /** + * @inheritdoc + */ + async list(projectId, directory = '') { + const url = `${this.basePath}/api/assets/${projectId}/list`; + const params = directory ? `?directory=${encodeURIComponent(directory)}` : ''; + return this.http.get(`${url}${params}`); + } + + /** + * @inheritdoc + */ + async exists(projectId, path) { + try { + const url = `${this.basePath}/api/assets/${projectId}/${encodeURIComponent(path)}/exists`; + const result = await this.http.get(url); + return result?.exists ?? false; + } catch (error) { + if (error instanceof NetworkError && error.statusCode === 404) { + return false; + } + throw error; + } + } + + /** + * @inheritdoc + */ + async copy(projectId, srcPath, destPath) { + const url = `${this.basePath}/api/assets/${projectId}/copy`; + return this.http.post(url, { src: srcPath, dest: destPath }); + } + + /** + * @inheritdoc + */ + async move(projectId, srcPath, destPath) { + const url = `${this.basePath}/api/assets/${projectId}/move`; + return this.http.post(url, { src: srcPath, dest: destPath }); + } + + /** + * Upload file via file manager. + * @param {string} projectId + * @param {File} file + * @param {string} directory + * @returns {Promise} + */ + async uploadViaFileManager(projectId, file, directory = '') { + const formData = new FormData(); + formData.append('file', file); + if (directory) { + formData.append('directory', directory); + } + + const url = `${this.basePath}/api/filemanager/${projectId}/upload`; + return this.http.upload(url, formData); + } + + /** + * List files in file manager. + * @param {string} projectId + * @param {string} directory + * @returns {Promise} + */ + async listFiles(projectId, directory = '') { + const url = `${this.basePath}/api/filemanager/${projectId}/list`; + const params = directory ? `?directory=${encodeURIComponent(directory)}` : ''; + return this.http.get(`${url}${params}`); + } + + /** + * Create directory in file manager. + * @param {string} projectId + * @param {string} path + * @returns {Promise} + */ + async createDirectory(projectId, path) { + const url = `${this.basePath}/api/filemanager/${projectId}/mkdir`; + return this.http.post(url, { path }); + } + + /** + * Delete file or directory in file manager. + * @param {string} projectId + * @param {string} path + * @returns {Promise} + */ + async deleteFile(projectId, path) { + const url = `${this.basePath}/api/filemanager/${projectId}/delete`; + return this.http.post(url, { path }); + } + + /** + * Rename file or directory in file manager. + * @param {string} projectId + * @param {string} oldPath + * @param {string} newPath + * @returns {Promise} + */ + async rename(projectId, oldPath, newPath) { + const url = `${this.basePath}/api/filemanager/${projectId}/rename`; + return this.http.post(url, { oldPath, newPath }); + } +} + +export default ServerAssetAdapter; diff --git a/public/app/core/adapters/server/ServerAssetAdapter.test.js b/public/app/core/adapters/server/ServerAssetAdapter.test.js new file mode 100644 index 000000000..242caab5c --- /dev/null +++ b/public/app/core/adapters/server/ServerAssetAdapter.test.js @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ServerAssetAdapter } from './ServerAssetAdapter.js'; + +describe('ServerAssetAdapter', () => { + let adapter; + let mockHttpClient; + + beforeEach(() => { + mockHttpClient = { + baseUrl: 'http://localhost:8083', + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + upload: vi.fn(), + downloadBlob: vi.fn(), + }; + + adapter = new ServerAssetAdapter(mockHttpClient, '/test'); + }); + + describe('constructor', () => { + it('should store httpClient and basePath', () => { + expect(adapter.http).toBe(mockHttpClient); + expect(adapter.basePath).toBe('/test'); + }); + + it('should default basePath to empty string', () => { + const adapterWithoutPath = new ServerAssetAdapter(mockHttpClient); + expect(adapterWithoutPath.basePath).toBe(''); + }); + }); + + describe('upload', () => { + it('should upload file with FormData', async () => { + mockHttpClient.upload.mockResolvedValue({ path: 'images/test.png' }); + + const file = new File(['content'], 'test.png', { type: 'image/png' }); + const result = await adapter.upload('project-123', file, 'images/test.png'); + + expect(mockHttpClient.upload).toHaveBeenCalledWith( + '/test/api/assets/project-123/upload', + expect.any(FormData), + ); + expect(result).toEqual({ path: 'images/test.png' }); + }); + }); + + describe('getUrl', () => { + it('should return asset URL', async () => { + const url = await adapter.getUrl('project-123', 'images/test.png'); + + expect(url).toBe('http://localhost:8083/test/api/assets/project-123/images%2Ftest.png'); + }); + + it('should encode special characters in path', async () => { + const url = await adapter.getUrl('project-123', 'images/my file.png'); + + expect(url).toBe('http://localhost:8083/test/api/assets/project-123/images%2Fmy%20file.png'); + }); + }); + + describe('getBlob', () => { + it('should download blob from server', async () => { + const mockBlob = new Blob(['content']); + mockHttpClient.downloadBlob.mockResolvedValue(mockBlob); + + const result = await adapter.getBlob('project-123', 'images/test.png'); + + expect(mockHttpClient.downloadBlob).toHaveBeenCalledWith( + '/test/api/assets/project-123/images%2Ftest.png', + ); + expect(result).toBe(mockBlob); + }); + }); + + describe('delete', () => { + it('should delete asset', async () => { + mockHttpClient.delete.mockResolvedValue({ success: true }); + + const result = await adapter.delete('project-123', 'images/test.png'); + + expect(mockHttpClient.delete).toHaveBeenCalledWith( + '/test/api/assets/project-123/images%2Ftest.png', + ); + expect(result).toEqual({ success: true }); + }); + }); + + describe('list', () => { + it('should list assets without directory', async () => { + mockHttpClient.get.mockResolvedValue([{ path: 'file1.png' }]); + + const result = await adapter.list('project-123'); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/test/api/assets/project-123/list'); + expect(result).toEqual([{ path: 'file1.png' }]); + }); + + it('should list assets with directory filter', async () => { + mockHttpClient.get.mockResolvedValue([{ path: 'images/file1.png' }]); + + const result = await adapter.list('project-123', 'images'); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + '/test/api/assets/project-123/list?directory=images', + ); + expect(result).toEqual([{ path: 'images/file1.png' }]); + }); + }); + + describe('exists', () => { + it('should return true if asset exists', async () => { + mockHttpClient.get.mockResolvedValue({ exists: true }); + + const result = await adapter.exists('project-123', 'images/test.png'); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + '/test/api/assets/project-123/images%2Ftest.png/exists', + ); + expect(result).toBe(true); + }); + + it('should return false if asset does not exist', async () => { + mockHttpClient.get.mockResolvedValue({ exists: false }); + + const result = await adapter.exists('project-123', 'nonexistent.png'); + + expect(result).toBe(false); + }); + + it('should return false on 404 error', async () => { + const { NetworkError } = await import('../../errors.js'); + const error = new NetworkError('Not found', 404); + mockHttpClient.get.mockRejectedValue(error); + + const result = await adapter.exists('project-123', 'nonexistent.png'); + + expect(result).toBe(false); + }); + + it('should throw on other errors', async () => { + const { NetworkError } = await import('../../errors.js'); + const error = new NetworkError('Server error', 500); + mockHttpClient.get.mockRejectedValue(error); + + await expect(adapter.exists('project-123', 'test.png')).rejects.toThrow(); + }); + }); + + describe('copy', () => { + it('should copy asset', async () => { + mockHttpClient.post.mockResolvedValue({ success: true }); + + const result = await adapter.copy('project-123', 'src.png', 'dest.png'); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/test/api/assets/project-123/copy', + { src: 'src.png', dest: 'dest.png' }, + ); + expect(result).toEqual({ success: true }); + }); + }); + + describe('move', () => { + it('should move asset', async () => { + mockHttpClient.post.mockResolvedValue({ success: true }); + + const result = await adapter.move('project-123', 'old.png', 'new.png'); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/test/api/assets/project-123/move', + { src: 'old.png', dest: 'new.png' }, + ); + expect(result).toEqual({ success: true }); + }); + }); + + describe('uploadViaFileManager', () => { + it('should upload via file manager', async () => { + mockHttpClient.upload.mockResolvedValue({ path: 'test.png' }); + + const file = new File(['content'], 'test.png'); + const result = await adapter.uploadViaFileManager('project-123', file, 'images'); + + expect(mockHttpClient.upload).toHaveBeenCalledWith( + '/test/api/filemanager/project-123/upload', + expect.any(FormData), + ); + expect(result).toEqual({ path: 'test.png' }); + }); + + it('should upload without directory', async () => { + mockHttpClient.upload.mockResolvedValue({ path: 'test.png' }); + + const file = new File(['content'], 'test.png'); + await adapter.uploadViaFileManager('project-123', file); + + expect(mockHttpClient.upload).toHaveBeenCalled(); + }); + }); + + describe('listFiles', () => { + it('should list files without directory', async () => { + mockHttpClient.get.mockResolvedValue([{ name: 'file1.png' }]); + + const result = await adapter.listFiles('project-123'); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/test/api/filemanager/project-123/list'); + expect(result).toEqual([{ name: 'file1.png' }]); + }); + + it('should list files with directory', async () => { + mockHttpClient.get.mockResolvedValue([{ name: 'file1.png' }]); + + const result = await adapter.listFiles('project-123', 'images'); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + '/test/api/filemanager/project-123/list?directory=images', + ); + expect(result).toEqual([{ name: 'file1.png' }]); + }); + }); + + describe('createDirectory', () => { + it('should create directory', async () => { + mockHttpClient.post.mockResolvedValue({ success: true }); + + const result = await adapter.createDirectory('project-123', 'new-folder'); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/test/api/filemanager/project-123/mkdir', + { path: 'new-folder' }, + ); + expect(result).toEqual({ success: true }); + }); + }); + + describe('deleteFile', () => { + it('should delete file', async () => { + mockHttpClient.post.mockResolvedValue({ success: true }); + + const result = await adapter.deleteFile('project-123', 'images/test.png'); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/test/api/filemanager/project-123/delete', + { path: 'images/test.png' }, + ); + expect(result).toEqual({ success: true }); + }); + }); + + describe('rename', () => { + it('should rename file', async () => { + mockHttpClient.post.mockResolvedValue({ success: true }); + + const result = await adapter.rename('project-123', 'old.png', 'new.png'); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/test/api/filemanager/project-123/rename', + { oldPath: 'old.png', newPath: 'new.png' }, + ); + expect(result).toEqual({ success: true }); + }); + }); +}); diff --git a/public/app/core/adapters/server/ServerCatalogAdapter.js b/public/app/core/adapters/server/ServerCatalogAdapter.js new file mode 100644 index 000000000..00a01ef86 --- /dev/null +++ b/public/app/core/adapters/server/ServerCatalogAdapter.js @@ -0,0 +1,270 @@ +/** + * ServerCatalogAdapter - Server-side implementation of CatalogPort. + * Handles catalog data (iDevices, themes, locales) via HTTP API. + */ +import { CatalogPort } from '../../ports/CatalogPort.js'; + +export class ServerCatalogAdapter extends CatalogPort { + /** + * @param {import('../../HttpClient').HttpClient} httpClient + * @param {Object} endpoints - API endpoints from parameters + */ + constructor(httpClient, endpoints = {}) { + super(); + this.http = httpClient; + this.endpoints = endpoints; + } + + /** + * Get endpoint URL by name. + * @private + */ + _getEndpoint(name) { + return this.endpoints[name]?.path || null; + } + + /** + * @inheritdoc + */ + async getIDevices() { + const url = this._getEndpoint('api_idevices_installed'); + if (!url) { + console.warn('[ServerCatalogAdapter] api_idevices_installed endpoint not found'); + return []; + } + return this.http.get(url); + } + + /** + * @inheritdoc + */ + async getThemes() { + const url = this._getEndpoint('api_themes_installed'); + if (!url) { + console.warn('[ServerCatalogAdapter] api_themes_installed endpoint not found'); + return []; + } + return this.http.get(url); + } + + /** + * @inheritdoc + */ + async getLocales() { + const url = this._getEndpoint('api_locales'); + if (!url) { + // Fallback to constructed URL + return this.http.get('/api/locales'); + } + return this.http.get(url); + } + + /** + * @inheritdoc + */ + async getTranslations(locale) { + const url = this._getEndpoint('api_translations'); + if (!url) { + // Fallback to constructed URL + return this.http.get(`/api/translations/${locale}`); + } + const translationsUrl = url.replace('{locale}', locale); + return this.http.get(translationsUrl); + } + + /** + * @inheritdoc + */ + async getIDevice(id) { + const idevices = await this.getIDevices(); + return idevices.find(idev => idev.id === id || idev.name === id) || null; + } + + /** + * @inheritdoc + */ + async getTheme(id) { + const themes = await this.getThemes(); + return themes.find(theme => theme.id === id || theme.name === id) || null; + } + + /** + * @inheritdoc + */ + async getLicenses() { + const url = this._getEndpoint('api_licenses'); + if (!url) { + return this.http.get('/api/licenses'); + } + return this.http.get(url); + } + + /** + * @inheritdoc + */ + async getExportFormats() { + const url = this._getEndpoint('api_export_formats'); + if (!url) { + return this.http.get('/api/export/formats'); + } + return this.http.get(url); + } + + /** + * Get API parameters/routes configuration. + * @returns {Promise} + */ + async getApiParameters() { + const basePath = window.eXeLearning?.config?.basePath || ''; + const url = `/api/parameter-management/parameters/data/list`; + return this.http.get(`${basePath}${url}`); + } + + /** + * Get upload limits configuration. + * @returns {Promise} + */ + async getUploadLimits() { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.get(`${basePath}/api/config/upload-limits`); + } + + /** + * Get templates for a locale. + * @param {string} locale + * @returns {Promise} + */ + async getTemplates(locale) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.get(`${basePath}/api/templates?locale=${locale}`); + } + + /** + * Get changelog text. + * @returns {Promise} + */ + async getChangelog() { + const url = window.eXeLearning?.config?.changelogURL; + if (!url) { + return ''; + } + const version = window.eXeLearning?.app?.common?.getVersionTimeStamp?.() || Date.now(); + try { + const response = await fetch(`${url}?version=${version}`); + return response.ok ? response.text() : ''; + } catch { + return ''; + } + } + + /** + * Get third-party code info. + * @returns {Promise} + */ + async getThirdPartyCode() { + const basePath = window.eXeLearning?.config?.basePath || ''; + const baseUrl = window.eXeLearning?.config?.baseURL || ''; + const version = window.eXeLearning?.version || 'v1.0.0'; + const url = `${baseUrl}${basePath}/${version}/libs/README.md`; + try { + const response = await fetch(url); + return response.ok ? response.text() : ''; + } catch { + return ''; + } + } + + /** + * Get licenses list. + * @returns {Promise} + */ + async getLicensesList() { + const basePath = window.eXeLearning?.config?.basePath || ''; + const baseUrl = window.eXeLearning?.config?.baseURL || ''; + const version = window.eXeLearning?.version || 'v1.0.0'; + const url = `${baseUrl}${basePath}/${version}/libs/LICENSES`; + try { + const response = await fetch(url); + return response.ok ? response.text() : ''; + } catch { + return ''; + } + } + + /** + * Get HTML template for a component. + * @param {string} componentId - Component sync ID + * @returns {Promise<{htmlTemplate: string, responseMessage: string}>} + */ + async getComponentHtmlTemplate(componentId) { + const url = this._getEndpoint('api_idevices_html_template_get'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.get(`${basePath}/api/idevices/${componentId}/template`); + } + const templateUrl = url.replace('{odeComponentsSyncId}', componentId); + return this.http.get(templateUrl); + } + + /** + * Create a new theme. + * @param {Object} params - Theme creation parameters + * @returns {Promise<{responseMessage: string}>} + */ + async createTheme(params) { + const url = this._getEndpoint('api_themes_new'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.post(`${basePath}/api/themes/new`, params); + } + return this.http.post(url, params); + } + + /** + * Update/edit an existing theme. + * @param {string} themeDir - Theme directory name + * @param {Object} params - Theme update parameters + * @returns {Promise<{responseMessage: string}>} + */ + async updateTheme(themeDir, params) { + const url = this._getEndpoint('api_themes_edit'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.put(`${basePath}/api/themes/${themeDir}`, params); + } + const editUrl = url.replace('{themeId}', themeDir); + return this.http.put(editUrl, params); + } + + /** + * Get saved HTML view for a component. + * @param {string} componentId - Component sync ID + * @returns {Promise<{responseMessage: string, htmlView: string}>} + */ + async getSaveHtmlView(componentId) { + const url = this._getEndpoint('api_idevices_html_view_get'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.get(`${basePath}/api/idevices/${componentId}/htmlview`); + } + const viewUrl = url.replace('{odeComponentsSyncId}', componentId); + return this.http.get(viewUrl); + } + + /** + * Get iDevices by session ID (games API). + * @param {string} sessionId - ODE session ID + * @returns {Promise<{responseMessage: string, idevices: Array}>} + */ + async getIdevicesBySessionId(sessionId) { + const url = this._getEndpoint('api_games_session_idevices'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.get(`${basePath}/api/games/session/${sessionId}/idevices`); + } + const gamesUrl = url.replace('{odeSessionId}', sessionId); + return this.http.get(gamesUrl); + } +} + +export default ServerCatalogAdapter; diff --git a/public/app/core/adapters/server/ServerCatalogAdapter.test.js b/public/app/core/adapters/server/ServerCatalogAdapter.test.js new file mode 100644 index 000000000..07f96cf82 --- /dev/null +++ b/public/app/core/adapters/server/ServerCatalogAdapter.test.js @@ -0,0 +1,331 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ServerCatalogAdapter } from './ServerCatalogAdapter.js'; + +describe('ServerCatalogAdapter', () => { + let adapter; + let mockHttpClient; + let mockEndpoints; + + beforeEach(() => { + mockHttpClient = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + }; + + mockEndpoints = { + api_idevices_installed: { path: '/api/idevices/installed' }, + api_themes_installed: { path: '/api/themes/installed' }, + api_locales: { path: '/api/locales' }, + api_translations: { path: '/api/translations/{locale}' }, + api_licenses: { path: '/api/licenses' }, + api_export_formats: { path: '/api/export/formats' }, + api_idevices_html_template_get: { path: '/api/idevices/{odeComponentsSyncId}/template' }, + api_themes_new: { path: '/api/themes/new' }, + api_themes_edit: { path: '/api/themes/{themeId}' }, + api_idevices_html_view_get: { path: '/api/idevices/{odeComponentsSyncId}/htmlview' }, + api_games_session_idevices: { path: '/api/games/session/{odeSessionId}/idevices' }, + }; + + adapter = new ServerCatalogAdapter(mockHttpClient, mockEndpoints); + + // Mock window.eXeLearning for methods that use it + window.eXeLearning = { + config: { + basePath: '', + baseURL: 'http://localhost:8083', + changelogURL: '/CHANGELOG.md', + }, + version: 'v1.0.0', + }; + }); + + describe('constructor', () => { + it('should store http client and endpoints', () => { + expect(adapter.http).toBe(mockHttpClient); + expect(adapter.endpoints).toBe(mockEndpoints); + }); + + it('should default to empty endpoints if not provided', () => { + const adapterWithoutEndpoints = new ServerCatalogAdapter(mockHttpClient); + expect(adapterWithoutEndpoints.endpoints).toEqual({}); + }); + }); + + describe('_getEndpoint', () => { + it('should return endpoint path if exists', () => { + expect(adapter._getEndpoint('api_idevices_installed')).toBe('/api/idevices/installed'); + }); + + it('should return null if endpoint not found', () => { + expect(adapter._getEndpoint('nonexistent')).toBeNull(); + }); + }); + + describe('getIDevices', () => { + it('should call http.get with correct URL', async () => { + const mockIdevices = [{ id: '1', name: 'text' }]; + mockHttpClient.get.mockResolvedValue(mockIdevices); + + const result = await adapter.getIDevices(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/idevices/installed'); + expect(result).toEqual(mockIdevices); + }); + + it('should return empty array if endpoint not found', async () => { + const adapterWithoutEndpoints = new ServerCatalogAdapter(mockHttpClient, {}); + + const result = await adapterWithoutEndpoints.getIDevices(); + + expect(result).toEqual([]); + }); + }); + + describe('getThemes', () => { + it('should call http.get with correct URL', async () => { + const mockThemes = { themes: [{ name: 'base' }] }; + mockHttpClient.get.mockResolvedValue(mockThemes); + + const result = await adapter.getThemes(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/themes/installed'); + expect(result).toEqual(mockThemes); + }); + + it('should return empty array if endpoint not found', async () => { + const adapterWithoutEndpoints = new ServerCatalogAdapter(mockHttpClient, {}); + + const result = await adapterWithoutEndpoints.getThemes(); + + expect(result).toEqual([]); + }); + }); + + describe('getLocales', () => { + it('should call http.get with endpoint URL', async () => { + const mockLocales = ['en', 'es']; + mockHttpClient.get.mockResolvedValue(mockLocales); + + const result = await adapter.getLocales(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/locales'); + expect(result).toEqual(mockLocales); + }); + + it('should fallback to constructed URL if endpoint not found', async () => { + const adapterWithoutEndpoints = new ServerCatalogAdapter(mockHttpClient, {}); + mockHttpClient.get.mockResolvedValue(['en']); + + await adapterWithoutEndpoints.getLocales(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/locales'); + }); + }); + + describe('getTranslations', () => { + it('should replace locale placeholder in URL', async () => { + const mockTranslations = { 'Hello': 'Hola' }; + mockHttpClient.get.mockResolvedValue(mockTranslations); + + const result = await adapter.getTranslations('es'); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/translations/es'); + expect(result).toEqual(mockTranslations); + }); + + it('should fallback to constructed URL if endpoint not found', async () => { + const adapterWithoutEndpoints = new ServerCatalogAdapter(mockHttpClient, {}); + mockHttpClient.get.mockResolvedValue({}); + + await adapterWithoutEndpoints.getTranslations('fr'); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/translations/fr'); + }); + }); + + describe('getIDevice', () => { + it('should find iDevice by id', async () => { + const mockIdevices = [ + { id: '1', name: 'text' }, + { id: '2', name: 'quiz' }, + ]; + mockHttpClient.get.mockResolvedValue(mockIdevices); + + const result = await adapter.getIDevice('1'); + + expect(result).toEqual({ id: '1', name: 'text' }); + }); + + it('should find iDevice by name', async () => { + const mockIdevices = [ + { id: '1', name: 'text' }, + { id: '2', name: 'quiz' }, + ]; + mockHttpClient.get.mockResolvedValue(mockIdevices); + + const result = await adapter.getIDevice('quiz'); + + expect(result).toEqual({ id: '2', name: 'quiz' }); + }); + + it('should return null if not found', async () => { + mockHttpClient.get.mockResolvedValue([]); + + const result = await adapter.getIDevice('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('getTheme', () => { + it('should find theme by id', async () => { + const mockThemes = [ + { id: 'base', name: 'base' }, + { id: 'flux', name: 'flux' }, + ]; + mockHttpClient.get.mockResolvedValue(mockThemes); + + const result = await adapter.getTheme('base'); + + expect(result).toEqual({ id: 'base', name: 'base' }); + }); + + it('should return null if not found', async () => { + mockHttpClient.get.mockResolvedValue([]); + + const result = await adapter.getTheme('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('getLicenses', () => { + it('should call http.get with endpoint URL', async () => { + const mockLicenses = [{ id: 'cc-by' }]; + mockHttpClient.get.mockResolvedValue(mockLicenses); + + const result = await adapter.getLicenses(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/licenses'); + expect(result).toEqual(mockLicenses); + }); + + it('should fallback to constructed URL', async () => { + const adapterWithoutEndpoints = new ServerCatalogAdapter(mockHttpClient, {}); + mockHttpClient.get.mockResolvedValue([]); + + await adapterWithoutEndpoints.getLicenses(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/licenses'); + }); + }); + + describe('getExportFormats', () => { + it('should call http.get with endpoint URL', async () => { + const mockFormats = ['html5', 'scorm12']; + mockHttpClient.get.mockResolvedValue(mockFormats); + + const result = await adapter.getExportFormats(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/export/formats'); + expect(result).toEqual(mockFormats); + }); + }); + + describe('getApiParameters', () => { + it('should call http.get with correct URL', async () => { + const mockParams = { routes: {} }; + mockHttpClient.get.mockResolvedValue(mockParams); + + const result = await adapter.getApiParameters(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/parameter-management/parameters/data/list'); + expect(result).toEqual(mockParams); + }); + }); + + describe('getUploadLimits', () => { + it('should call http.get with correct URL', async () => { + const mockLimits = { maxFileSize: 1000000 }; + mockHttpClient.get.mockResolvedValue(mockLimits); + + const result = await adapter.getUploadLimits(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/config/upload-limits'); + expect(result).toEqual(mockLimits); + }); + }); + + describe('getTemplates', () => { + it('should call http.get with locale param', async () => { + const mockTemplates = [{ id: 'blank' }]; + mockHttpClient.get.mockResolvedValue(mockTemplates); + + const result = await adapter.getTemplates('es'); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/templates?locale=es'); + expect(result).toEqual(mockTemplates); + }); + }); + + describe('getComponentHtmlTemplate', () => { + it('should replace component ID in URL', async () => { + const mockTemplate = { htmlTemplate: '
' }; + mockHttpClient.get.mockResolvedValue(mockTemplate); + + const result = await adapter.getComponentHtmlTemplate('comp-123'); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/idevices/comp-123/template'); + expect(result).toEqual(mockTemplate); + }); + }); + + describe('createTheme', () => { + it('should call http.post with correct URL and params', async () => { + const params = { name: 'newtheme' }; + mockHttpClient.post.mockResolvedValue({ responseMessage: 'OK' }); + + const result = await adapter.createTheme(params); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/themes/new', params); + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('updateTheme', () => { + it('should replace theme ID in URL', async () => { + const params = { name: 'updated' }; + mockHttpClient.put.mockResolvedValue({ responseMessage: 'OK' }); + + const result = await adapter.updateTheme('mytheme', params); + + expect(mockHttpClient.put).toHaveBeenCalledWith('/api/themes/mytheme', params); + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('getSaveHtmlView', () => { + it('should replace component ID in URL', async () => { + const mockView = { htmlView: '
content
' }; + mockHttpClient.get.mockResolvedValue(mockView); + + const result = await adapter.getSaveHtmlView('comp-456'); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/idevices/comp-456/htmlview'); + expect(result).toEqual(mockView); + }); + }); + + describe('getIdevicesBySessionId', () => { + it('should replace session ID in URL', async () => { + const mockIdevices = { idevices: [] }; + mockHttpClient.get.mockResolvedValue(mockIdevices); + + const result = await adapter.getIdevicesBySessionId('session-789'); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/games/session/session-789/idevices'); + expect(result).toEqual(mockIdevices); + }); + }); +}); diff --git a/public/app/core/adapters/server/ServerCloudStorageAdapter.js b/public/app/core/adapters/server/ServerCloudStorageAdapter.js new file mode 100644 index 000000000..fe7d63b9d --- /dev/null +++ b/public/app/core/adapters/server/ServerCloudStorageAdapter.js @@ -0,0 +1,100 @@ +/** + * ServerCloudStorageAdapter - Server-side implementation of CloudStoragePort. + * Handles cloud storage operations via HTTP API. + */ +import { CloudStoragePort } from '../../ports/CloudStoragePort.js'; + +export class ServerCloudStorageAdapter extends CloudStoragePort { + /** + * @param {import('../../HttpClient').HttpClient} httpClient + * @param {Object} endpoints - API endpoints from parameters + */ + constructor(httpClient, endpoints = {}) { + super(); + this.http = httpClient; + this.endpoints = endpoints; + } + + /** + * Get endpoint URL by name. + * @private + */ + _getEndpoint(name) { + return this.endpoints[name]?.path || null; + } + + /** + * @inheritdoc + */ + async getGoogleDriveLoginUrl() { + const url = this._getEndpoint('api_google_oauth_login_url_get'); + if (!url) { + return { responseMessage: 'ENDPOINT_NOT_CONFIGURED', url: null }; + } + return this.http.get(url); + } + + /** + * @inheritdoc + */ + async getGoogleDriveFolders() { + const url = this._getEndpoint('api_google_drive_folders_list'); + if (!url) { + return { responseMessage: 'ENDPOINT_NOT_CONFIGURED', folders: [] }; + } + return this.http.get(url); + } + + /** + * @inheritdoc + */ + async uploadToGoogleDrive(params) { + const url = this._getEndpoint('api_google_drive_file_upload'); + if (!url) { + return { responseMessage: 'ENDPOINT_NOT_CONFIGURED' }; + } + return this.http.post(url, params); + } + + /** + * @inheritdoc + */ + async getDropboxLoginUrl() { + const url = this._getEndpoint('api_dropbox_oauth_login_url_get'); + if (!url) { + return { responseMessage: 'ENDPOINT_NOT_CONFIGURED', url: null }; + } + return this.http.get(url); + } + + /** + * @inheritdoc + */ + async getDropboxFolders() { + const url = this._getEndpoint('api_dropbox_folders_list'); + if (!url) { + return { responseMessage: 'ENDPOINT_NOT_CONFIGURED', folders: [] }; + } + return this.http.get(url); + } + + /** + * @inheritdoc + */ + async uploadToDropbox(params) { + const url = this._getEndpoint('api_dropbox_file_upload'); + if (!url) { + return { responseMessage: 'ENDPOINT_NOT_CONFIGURED' }; + } + return this.http.post(url, params); + } + + /** + * @inheritdoc + */ + isSupported() { + return true; + } +} + +export default ServerCloudStorageAdapter; diff --git a/public/app/core/adapters/server/ServerCloudStorageAdapter.test.js b/public/app/core/adapters/server/ServerCloudStorageAdapter.test.js new file mode 100644 index 000000000..8d96b25ad --- /dev/null +++ b/public/app/core/adapters/server/ServerCloudStorageAdapter.test.js @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ServerCloudStorageAdapter } from './ServerCloudStorageAdapter.js'; + +describe('ServerCloudStorageAdapter', () => { + let adapter; + let mockHttpClient; + let mockEndpoints; + + beforeEach(() => { + mockHttpClient = { + get: vi.fn(), + post: vi.fn(), + }; + + mockEndpoints = { + api_google_oauth_login_url_get: { path: '/api/google/oauth/login' }, + api_google_drive_folders_list: { path: '/api/google/drive/folders' }, + api_google_drive_file_upload: { path: '/api/google/drive/upload' }, + api_dropbox_oauth_login_url_get: { path: '/api/dropbox/oauth/login' }, + api_dropbox_folders_list: { path: '/api/dropbox/folders' }, + api_dropbox_file_upload: { path: '/api/dropbox/upload' }, + }; + + adapter = new ServerCloudStorageAdapter(mockHttpClient, mockEndpoints); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should store httpClient and endpoints', () => { + expect(adapter.http).toBe(mockHttpClient); + expect(adapter.endpoints).toBe(mockEndpoints); + }); + + it('should default endpoints to empty object', () => { + const adapterWithoutEndpoints = new ServerCloudStorageAdapter(mockHttpClient); + expect(adapterWithoutEndpoints.endpoints).toEqual({}); + }); + }); + + describe('_getEndpoint', () => { + it('should return endpoint path if exists', () => { + expect(adapter._getEndpoint('api_google_oauth_login_url_get')) + .toBe('/api/google/oauth/login'); + }); + + it('should return null if endpoint not found', () => { + expect(adapter._getEndpoint('nonexistent')).toBeNull(); + }); + }); + + describe('getGoogleDriveLoginUrl', () => { + it('should call http.get with correct endpoint', async () => { + mockHttpClient.get.mockResolvedValue({ url: 'https://google.com/oauth' }); + + const result = await adapter.getGoogleDriveLoginUrl(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/google/oauth/login'); + expect(result.url).toBe('https://google.com/oauth'); + }); + + it('should return ENDPOINT_NOT_CONFIGURED if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerCloudStorageAdapter(mockHttpClient, {}); + + const result = await adapterWithoutEndpoints.getGoogleDriveLoginUrl(); + + expect(result).toEqual({ responseMessage: 'ENDPOINT_NOT_CONFIGURED', url: null }); + expect(mockHttpClient.get).not.toHaveBeenCalled(); + }); + }); + + describe('getGoogleDriveFolders', () => { + it('should call http.get with correct endpoint', async () => { + const folders = [{ id: '1', name: 'Folder 1' }]; + mockHttpClient.get.mockResolvedValue({ folders }); + + const result = await adapter.getGoogleDriveFolders(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/google/drive/folders'); + expect(result.folders).toEqual(folders); + }); + + it('should return ENDPOINT_NOT_CONFIGURED if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerCloudStorageAdapter(mockHttpClient, {}); + + const result = await adapterWithoutEndpoints.getGoogleDriveFolders(); + + expect(result).toEqual({ responseMessage: 'ENDPOINT_NOT_CONFIGURED', folders: [] }); + }); + }); + + describe('uploadToGoogleDrive', () => { + it('should call http.post with correct endpoint and params', async () => { + const params = { folderId: '123', fileData: 'base64data' }; + mockHttpClient.post.mockResolvedValue({ success: true }); + + const result = await adapter.uploadToGoogleDrive(params); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/google/drive/upload', params); + expect(result.success).toBe(true); + }); + + it('should return ENDPOINT_NOT_CONFIGURED if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerCloudStorageAdapter(mockHttpClient, {}); + + const result = await adapterWithoutEndpoints.uploadToGoogleDrive({}); + + expect(result).toEqual({ responseMessage: 'ENDPOINT_NOT_CONFIGURED' }); + }); + }); + + describe('getDropboxLoginUrl', () => { + it('should call http.get with correct endpoint', async () => { + mockHttpClient.get.mockResolvedValue({ url: 'https://dropbox.com/oauth' }); + + const result = await adapter.getDropboxLoginUrl(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/dropbox/oauth/login'); + expect(result.url).toBe('https://dropbox.com/oauth'); + }); + + it('should return ENDPOINT_NOT_CONFIGURED if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerCloudStorageAdapter(mockHttpClient, {}); + + const result = await adapterWithoutEndpoints.getDropboxLoginUrl(); + + expect(result).toEqual({ responseMessage: 'ENDPOINT_NOT_CONFIGURED', url: null }); + }); + }); + + describe('getDropboxFolders', () => { + it('should call http.get with correct endpoint', async () => { + const folders = [{ id: '1', name: 'Dropbox Folder' }]; + mockHttpClient.get.mockResolvedValue({ folders }); + + const result = await adapter.getDropboxFolders(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/dropbox/folders'); + expect(result.folders).toEqual(folders); + }); + + it('should return ENDPOINT_NOT_CONFIGURED if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerCloudStorageAdapter(mockHttpClient, {}); + + const result = await adapterWithoutEndpoints.getDropboxFolders(); + + expect(result).toEqual({ responseMessage: 'ENDPOINT_NOT_CONFIGURED', folders: [] }); + }); + }); + + describe('uploadToDropbox', () => { + it('should call http.post with correct endpoint and params', async () => { + const params = { path: '/folder', fileData: 'base64data' }; + mockHttpClient.post.mockResolvedValue({ success: true }); + + const result = await adapter.uploadToDropbox(params); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/dropbox/upload', params); + expect(result.success).toBe(true); + }); + + it('should return ENDPOINT_NOT_CONFIGURED if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerCloudStorageAdapter(mockHttpClient, {}); + + const result = await adapterWithoutEndpoints.uploadToDropbox({}); + + expect(result).toEqual({ responseMessage: 'ENDPOINT_NOT_CONFIGURED' }); + }); + }); + + describe('isSupported', () => { + it('should return true', () => { + expect(adapter.isSupported()).toBe(true); + }); + }); +}); diff --git a/public/app/core/adapters/server/ServerCollaborationAdapter.js b/public/app/core/adapters/server/ServerCollaborationAdapter.js new file mode 100644 index 000000000..5db204f6e --- /dev/null +++ b/public/app/core/adapters/server/ServerCollaborationAdapter.js @@ -0,0 +1,143 @@ +/** + * ServerCollaborationAdapter - Server-side implementation of CollaborationPort. + * Handles real-time collaboration via WebSocket. + */ +import { CollaborationPort } from '../../ports/CollaborationPort.js'; + +export class ServerCollaborationAdapter extends CollaborationPort { + /** + * @param {string} wsUrl - WebSocket base URL + * @param {string} [basePath] - API base path for REST endpoints + */ + constructor(wsUrl, basePath = '') { + super(); + this.wsUrl = wsUrl; + this.basePath = basePath; + this.currentProjectId = null; + this._presenceCallbacks = new Set(); + } + + /** + * @inheritdoc + */ + isEnabled() { + return true; + } + + /** + * @inheritdoc + */ + async connect(projectId) { + this.currentProjectId = projectId; + // Actual WebSocket connection is managed by YjsDocumentManager + // This is a coordination point + } + + /** + * @inheritdoc + */ + async disconnect() { + this.currentProjectId = null; + } + + /** + * @inheritdoc + */ + async getPresence() { + // In server mode, presence is managed by Yjs awareness + // This returns the current awareness states + const awareness = window.eXeLearning?.app?.project?._yjsBridge?.awareness; + if (!awareness) { + return []; + } + + const states = []; + awareness.getStates().forEach((state, clientId) => { + if (state.user) { + states.push({ + clientId, + userId: state.user.id || clientId.toString(), + username: state.user.name || 'Anonymous', + color: state.user.color || '#888888', + cursor: state.cursor, + }); + } + }); + + return states; + } + + /** + * @inheritdoc + */ + async updatePresence(data) { + const awareness = window.eXeLearning?.app?.project?._yjsBridge?.awareness; + if (!awareness) { + return; + } + + awareness.setLocalStateField('cursor', data.cursor); + awareness.setLocalStateField('selection', data.selection); + } + + /** + * @inheritdoc + */ + onPresenceChange(callback) { + this._presenceCallbacks.add(callback); + + // Subscribe to awareness changes + const awareness = window.eXeLearning?.app?.project?._yjsBridge?.awareness; + if (awareness) { + const handler = () => { + this.getPresence().then(presence => { + callback(presence); + }); + }; + awareness.on('change', handler); + + // Return unsubscribe function + return () => { + this._presenceCallbacks.delete(callback); + awareness.off('change', handler); + }; + } + + // Return no-op unsubscribe if awareness not available + return () => { + this._presenceCallbacks.delete(callback); + }; + } + + /** + * @inheritdoc + */ + getWebSocketUrl() { + return this.wsUrl; + } + + /** + * Get WebSocket URL for a specific project. + * @param {string} projectId + * @returns {string} + */ + getProjectWebSocketUrl(projectId) { + if (!this.wsUrl) { + return null; + } + return `${this.wsUrl}/yjs/${projectId}`; + } + + /** + * @inheritdoc + * In server mode with Yjs, block sync is automatic. + * This method is for legacy API compatibility. + */ + async obtainBlockSync(params) { + // In Yjs mode, synchronization is automatic + // Return null block indicating no server-side sync needed + return { responseMessage: 'OK', block: null }; + } +} + +export default ServerCollaborationAdapter; diff --git a/public/app/core/adapters/server/ServerCollaborationAdapter.test.js b/public/app/core/adapters/server/ServerCollaborationAdapter.test.js new file mode 100644 index 000000000..2e32af7ab --- /dev/null +++ b/public/app/core/adapters/server/ServerCollaborationAdapter.test.js @@ -0,0 +1,228 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ServerCollaborationAdapter } from './ServerCollaborationAdapter.js'; + +describe('ServerCollaborationAdapter', () => { + let adapter; + let mockAwareness; + + beforeEach(() => { + adapter = new ServerCollaborationAdapter('ws://localhost:8083', '/test'); + + mockAwareness = { + getStates: vi.fn().mockReturnValue(new Map([ + [1, { user: { id: 'user-1', name: 'Alice', color: '#ff0000' }, cursor: { x: 10, y: 20 } }], + [2, { user: { id: 'user-2', name: 'Bob', color: '#00ff00' } }], + ])), + setLocalStateField: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + window.eXeLearning = { + app: { + project: { + _yjsBridge: { + awareness: mockAwareness, + }, + }, + }, + }; + }); + + afterEach(() => { + delete window.eXeLearning; + }); + + describe('constructor', () => { + it('should store wsUrl and basePath', () => { + expect(adapter.wsUrl).toBe('ws://localhost:8083'); + expect(adapter.basePath).toBe('/test'); + }); + + it('should default basePath to empty string', () => { + const adapterWithoutPath = new ServerCollaborationAdapter('ws://test'); + expect(adapterWithoutPath.basePath).toBe(''); + }); + + it('should initialize currentProjectId as null', () => { + expect(adapter.currentProjectId).toBeNull(); + }); + + it('should initialize empty presence callbacks set', () => { + expect(adapter._presenceCallbacks).toBeInstanceOf(Set); + expect(adapter._presenceCallbacks.size).toBe(0); + }); + }); + + describe('isEnabled', () => { + it('should return true', () => { + expect(adapter.isEnabled()).toBe(true); + }); + }); + + describe('connect', () => { + it('should set currentProjectId', async () => { + await adapter.connect('project-123'); + + expect(adapter.currentProjectId).toBe('project-123'); + }); + }); + + describe('disconnect', () => { + it('should clear currentProjectId', async () => { + adapter.currentProjectId = 'project-123'; + + await adapter.disconnect(); + + expect(adapter.currentProjectId).toBeNull(); + }); + }); + + describe('getPresence', () => { + it('should return presence from awareness', async () => { + const presence = await adapter.getPresence(); + + expect(presence).toHaveLength(2); + expect(presence[0]).toEqual({ + clientId: 1, + userId: 'user-1', + username: 'Alice', + color: '#ff0000', + cursor: { x: 10, y: 20 }, + }); + expect(presence[1]).toEqual({ + clientId: 2, + userId: 'user-2', + username: 'Bob', + color: '#00ff00', + cursor: undefined, + }); + }); + + it('should return empty array if awareness not available', async () => { + delete window.eXeLearning; + + const presence = await adapter.getPresence(); + + expect(presence).toEqual([]); + }); + + it('should handle missing user data', async () => { + mockAwareness.getStates.mockReturnValue(new Map([ + [1, {}], // No user data + [2, { user: { name: 'Bob' } }], // Missing id and color + ])); + + const presence = await adapter.getPresence(); + + expect(presence).toHaveLength(1); + expect(presence[0].userId).toBe('2'); // Falls back to clientId + expect(presence[0].username).toBe('Bob'); + expect(presence[0].color).toBe('#888888'); // Default color + }); + }); + + describe('updatePresence', () => { + it('should update awareness state', async () => { + await adapter.updatePresence({ + cursor: { x: 100, y: 200 }, + selection: { start: 0, end: 10 }, + }); + + expect(mockAwareness.setLocalStateField).toHaveBeenCalledWith( + 'cursor', + { x: 100, y: 200 }, + ); + expect(mockAwareness.setLocalStateField).toHaveBeenCalledWith( + 'selection', + { start: 0, end: 10 }, + ); + }); + + it('should do nothing if awareness not available', async () => { + delete window.eXeLearning; + + await adapter.updatePresence({ cursor: { x: 0, y: 0 } }); + + // Should not throw + }); + }); + + describe('onPresenceChange', () => { + it('should subscribe to awareness changes', () => { + const callback = vi.fn(); + + adapter.onPresenceChange(callback); + + expect(adapter._presenceCallbacks.has(callback)).toBe(true); + expect(mockAwareness.on).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + it('should return unsubscribe function', () => { + const callback = vi.fn(); + + const unsubscribe = adapter.onPresenceChange(callback); + unsubscribe(); + + expect(adapter._presenceCallbacks.has(callback)).toBe(false); + expect(mockAwareness.off).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + it('should return no-op unsubscribe if awareness not available', () => { + delete window.eXeLearning; + const callback = vi.fn(); + + const unsubscribe = adapter.onPresenceChange(callback); + expect(typeof unsubscribe).toBe('function'); + + unsubscribe(); // Should not throw + }); + + it('should call callback with presence on change', () => { + const callback = vi.fn(); + + adapter.onPresenceChange(callback); + + // Get the handler that was registered + const registeredHandler = mockAwareness.on.mock.calls[0][1]; + + // Simulate change + registeredHandler(); + + // Wait for async operation + return new Promise(resolve => setTimeout(resolve, 0)).then(() => { + expect(callback).toHaveBeenCalled(); + }); + }); + }); + + describe('getWebSocketUrl', () => { + it('should return wsUrl', () => { + expect(adapter.getWebSocketUrl()).toBe('ws://localhost:8083'); + }); + }); + + describe('getProjectWebSocketUrl', () => { + it('should return project-specific WebSocket URL', () => { + const url = adapter.getProjectWebSocketUrl('project-123'); + + expect(url).toBe('ws://localhost:8083/yjs/project-123'); + }); + + it('should return null if no wsUrl', () => { + const adapterWithoutWs = new ServerCollaborationAdapter(null); + + const url = adapterWithoutWs.getProjectWebSocketUrl('project-123'); + + expect(url).toBeNull(); + }); + }); + + describe('obtainBlockSync', () => { + it('should return OK with null block (Yjs handles sync)', async () => { + const result = await adapter.obtainBlockSync({ blockId: '123' }); + + expect(result).toEqual({ responseMessage: 'OK', block: null }); + }); + }); +}); diff --git a/public/app/core/adapters/server/ServerContentAdapter.js b/public/app/core/adapters/server/ServerContentAdapter.js new file mode 100644 index 000000000..a600d65cc --- /dev/null +++ b/public/app/core/adapters/server/ServerContentAdapter.js @@ -0,0 +1,175 @@ +/** + * ServerContentAdapter - Server-side implementation of ContentPort. + * Handles content structure operations via HTTP API. + */ +import { ContentPort } from '../../ports/ContentPort.js'; + +export class ServerContentAdapter extends ContentPort { + /** + * @param {import('../../HttpClient').HttpClient} httpClient + * @param {Object} endpoints - API endpoints from parameters + */ + constructor(httpClient, endpoints = {}) { + super(); + this.http = httpClient; + this.endpoints = endpoints; + } + + /** + * Get endpoint URL by name. + * @private + */ + _getEndpoint(name) { + return this.endpoints[name]?.path || null; + } + + /** + * @inheritdoc + */ + async savePage(params) { + const url = this._getEndpoint('api_ode_page_edit'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.put(`${basePath}/api/page`, params); + } + return this.http.put(url, params); + } + + /** + * @inheritdoc + */ + async reorderPage(params) { + const url = this._getEndpoint('api_ode_page_reorder'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.put(`${basePath}/api/page/reorder`, params); + } + return this.http.put(url, params); + } + + /** + * @inheritdoc + */ + async clonePage(params) { + const url = this._getEndpoint('api_ode_page_clone'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.post(`${basePath}/api/page/clone`, params); + } + return this.http.post(url, params); + } + + /** + * @inheritdoc + */ + async deletePage(pageId) { + const url = this._getEndpoint('api_ode_page_delete'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.delete(`${basePath}/api/page/${pageId}`); + } + const deleteUrl = url.replace('{odeNavStructureSyncId}', pageId); + return this.http.delete(deleteUrl); + } + + /** + * @inheritdoc + */ + async reorderBlock(params) { + const url = this._getEndpoint('api_ode_block_reorder'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.put(`${basePath}/api/block/reorder`, params); + } + return this.http.put(url, params); + } + + /** + * @inheritdoc + */ + async deleteBlock(blockId) { + const url = this._getEndpoint('api_ode_block_delete'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.delete(`${basePath}/api/block/${blockId}`); + } + const deleteUrl = url.replace('{odeBlockSyncId}', blockId); + return this.http.delete(deleteUrl); + } + + /** + * @inheritdoc + */ + async reorderIdevice(params) { + const url = this._getEndpoint('api_idevices_idevice_reorder'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.put(`${basePath}/api/idevice/reorder`, params); + } + return this.http.put(url, params); + } + + /** + * @inheritdoc + */ + async saveIdevice(params) { + const url = this._getEndpoint('api_idevices_idevice_data_save'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.put(`${basePath}/api/idevice/save`, params); + } + return this.http.put(url, params); + } + + /** + * @inheritdoc + */ + async cloneIdevice(params) { + const url = this._getEndpoint('api_idevices_idevice_clone'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.post(`${basePath}/api/idevice/clone`, params); + } + return this.http.post(url, params); + } + + /** + * @inheritdoc + */ + async deleteIdevice(ideviceId) { + const url = this._getEndpoint('api_idevices_idevice_delete'); + if (!url) { + const basePath = window.eXeLearning?.config?.basePath || ''; + return this.http.delete(`${basePath}/api/idevice/${ideviceId}`); + } + const deleteUrl = url.replace('{odeComponentsSyncId}', ideviceId); + return this.http.delete(deleteUrl); + } + + /** + * @inheritdoc + */ + async send(endpointId, params) { + const endpoint = this.endpoints[endpointId]; + if (!endpoint) { + throw new Error(`Endpoint not found: ${endpointId}`); + } + const method = (endpoint.method || endpoint.methods?.[0] || 'GET').toLowerCase(); + const url = endpoint.path; + + switch (method) { + case 'get': + return this.http.get(url); + case 'post': + return this.http.post(url, params); + case 'put': + return this.http.put(url, params); + case 'delete': + return this.http.delete(url); + default: + return this.http.post(url, params); + } + } +} + +export default ServerContentAdapter; diff --git a/public/app/core/adapters/server/ServerContentAdapter.test.js b/public/app/core/adapters/server/ServerContentAdapter.test.js new file mode 100644 index 000000000..c97047d11 --- /dev/null +++ b/public/app/core/adapters/server/ServerContentAdapter.test.js @@ -0,0 +1,360 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ServerContentAdapter } from './ServerContentAdapter.js'; + +describe('ServerContentAdapter', () => { + let adapter; + let mockHttpClient; + let mockEndpoints; + + beforeEach(() => { + mockHttpClient = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }; + + mockEndpoints = { + api_ode_page_edit: { path: '/api/page' }, + api_ode_page_reorder: { path: '/api/page/reorder' }, + api_ode_page_clone: { path: '/api/page/clone' }, + api_ode_page_delete: { path: '/api/page/{odeNavStructureSyncId}' }, + api_ode_block_reorder: { path: '/api/block/reorder' }, + api_ode_block_delete: { path: '/api/block/{odeBlockSyncId}' }, + api_idevices_idevice_reorder: { path: '/api/idevice/reorder' }, + api_idevices_idevice_data_save: { path: '/api/idevice/save' }, + api_idevices_idevice_clone: { path: '/api/idevice/clone' }, + api_idevices_idevice_delete: { path: '/api/idevice/{odeComponentsSyncId}' }, + }; + + adapter = new ServerContentAdapter(mockHttpClient, mockEndpoints); + + window.eXeLearning = { config: { basePath: '' } }; + }); + + afterEach(() => { + delete window.eXeLearning; + }); + + describe('constructor', () => { + it('should store httpClient and endpoints', () => { + expect(adapter.http).toBe(mockHttpClient); + expect(adapter.endpoints).toBe(mockEndpoints); + }); + + it('should default endpoints to empty object', () => { + const adapterWithoutEndpoints = new ServerContentAdapter(mockHttpClient); + expect(adapterWithoutEndpoints.endpoints).toEqual({}); + }); + }); + + describe('_getEndpoint', () => { + it('should return endpoint path if exists', () => { + expect(adapter._getEndpoint('api_ode_page_edit')).toBe('/api/page'); + }); + + it('should return null if endpoint not found', () => { + expect(adapter._getEndpoint('nonexistent')).toBeNull(); + }); + }); + + describe('savePage', () => { + it('should save page using endpoint', async () => { + mockHttpClient.put.mockResolvedValue({ responseMessage: 'OK' }); + + const result = await adapter.savePage({ pageId: '123', title: 'Test' }); + + expect(mockHttpClient.put).toHaveBeenCalledWith( + '/api/page', + { pageId: '123', title: 'Test' }, + ); + expect(result).toEqual({ responseMessage: 'OK' }); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerContentAdapter(mockHttpClient, {}); + mockHttpClient.put.mockResolvedValue({ responseMessage: 'OK' }); + + await adapterWithoutEndpoints.savePage({ pageId: '123' }); + + expect(mockHttpClient.put).toHaveBeenCalledWith( + '/api/page', + { pageId: '123' }, + ); + }); + }); + + describe('reorderPage', () => { + it('should reorder page using endpoint', async () => { + mockHttpClient.put.mockResolvedValue({ responseMessage: 'OK' }); + + const result = await adapter.reorderPage({ order: [1, 2, 3] }); + + expect(mockHttpClient.put).toHaveBeenCalledWith( + '/api/page/reorder', + { order: [1, 2, 3] }, + ); + expect(result).toEqual({ responseMessage: 'OK' }); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerContentAdapter(mockHttpClient, {}); + mockHttpClient.put.mockResolvedValue({ responseMessage: 'OK' }); + + await adapterWithoutEndpoints.reorderPage({}); + + expect(mockHttpClient.put).toHaveBeenCalledWith('/api/page/reorder', {}); + }); + }); + + describe('clonePage', () => { + it('should clone page using endpoint', async () => { + mockHttpClient.post.mockResolvedValue({ responseMessage: 'OK', newId: 'new-123' }); + + const result = await adapter.clonePage({ pageId: '123' }); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/api/page/clone', + { pageId: '123' }, + ); + expect(result).toEqual({ responseMessage: 'OK', newId: 'new-123' }); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerContentAdapter(mockHttpClient, {}); + mockHttpClient.post.mockResolvedValue({ responseMessage: 'OK' }); + + await adapterWithoutEndpoints.clonePage({}); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/page/clone', {}); + }); + }); + + describe('deletePage', () => { + it('should delete page using endpoint with replaced placeholder', async () => { + mockHttpClient.delete.mockResolvedValue({ responseMessage: 'OK' }); + + const result = await adapter.deletePage('page-123'); + + expect(mockHttpClient.delete).toHaveBeenCalledWith('/api/page/page-123'); + expect(result).toEqual({ responseMessage: 'OK' }); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerContentAdapter(mockHttpClient, {}); + mockHttpClient.delete.mockResolvedValue({ responseMessage: 'OK' }); + + await adapterWithoutEndpoints.deletePage('page-123'); + + expect(mockHttpClient.delete).toHaveBeenCalledWith('/api/page/page-123'); + }); + }); + + describe('reorderBlock', () => { + it('should reorder block using endpoint', async () => { + mockHttpClient.put.mockResolvedValue({ responseMessage: 'OK' }); + + const result = await adapter.reorderBlock({ order: [1, 2] }); + + expect(mockHttpClient.put).toHaveBeenCalledWith( + '/api/block/reorder', + { order: [1, 2] }, + ); + expect(result).toEqual({ responseMessage: 'OK' }); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerContentAdapter(mockHttpClient, {}); + mockHttpClient.put.mockResolvedValue({ responseMessage: 'OK' }); + + await adapterWithoutEndpoints.reorderBlock({}); + + expect(mockHttpClient.put).toHaveBeenCalledWith('/api/block/reorder', {}); + }); + }); + + describe('deleteBlock', () => { + it('should delete block using endpoint with replaced placeholder', async () => { + mockHttpClient.delete.mockResolvedValue({ responseMessage: 'OK' }); + + const result = await adapter.deleteBlock('block-123'); + + expect(mockHttpClient.delete).toHaveBeenCalledWith('/api/block/block-123'); + expect(result).toEqual({ responseMessage: 'OK' }); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerContentAdapter(mockHttpClient, {}); + mockHttpClient.delete.mockResolvedValue({ responseMessage: 'OK' }); + + await adapterWithoutEndpoints.deleteBlock('block-123'); + + expect(mockHttpClient.delete).toHaveBeenCalledWith('/api/block/block-123'); + }); + }); + + describe('reorderIdevice', () => { + it('should reorder iDevice using endpoint', async () => { + mockHttpClient.put.mockResolvedValue({ responseMessage: 'OK' }); + + const result = await adapter.reorderIdevice({ order: [1, 2] }); + + expect(mockHttpClient.put).toHaveBeenCalledWith( + '/api/idevice/reorder', + { order: [1, 2] }, + ); + expect(result).toEqual({ responseMessage: 'OK' }); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerContentAdapter(mockHttpClient, {}); + mockHttpClient.put.mockResolvedValue({ responseMessage: 'OK' }); + + await adapterWithoutEndpoints.reorderIdevice({}); + + expect(mockHttpClient.put).toHaveBeenCalledWith('/api/idevice/reorder', {}); + }); + }); + + describe('saveIdevice', () => { + it('should save iDevice using endpoint', async () => { + mockHttpClient.put.mockResolvedValue({ responseMessage: 'OK' }); + + const result = await adapter.saveIdevice({ ideviceId: '123', content: 'test' }); + + expect(mockHttpClient.put).toHaveBeenCalledWith( + '/api/idevice/save', + { ideviceId: '123', content: 'test' }, + ); + expect(result).toEqual({ responseMessage: 'OK' }); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerContentAdapter(mockHttpClient, {}); + mockHttpClient.put.mockResolvedValue({ responseMessage: 'OK' }); + + await adapterWithoutEndpoints.saveIdevice({}); + + expect(mockHttpClient.put).toHaveBeenCalledWith('/api/idevice/save', {}); + }); + }); + + describe('cloneIdevice', () => { + it('should clone iDevice using endpoint', async () => { + mockHttpClient.post.mockResolvedValue({ responseMessage: 'OK', newId: 'new-456' }); + + const result = await adapter.cloneIdevice({ ideviceId: '123' }); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/api/idevice/clone', + { ideviceId: '123' }, + ); + expect(result).toEqual({ responseMessage: 'OK', newId: 'new-456' }); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerContentAdapter(mockHttpClient, {}); + mockHttpClient.post.mockResolvedValue({ responseMessage: 'OK' }); + + await adapterWithoutEndpoints.cloneIdevice({}); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/idevice/clone', {}); + }); + }); + + describe('deleteIdevice', () => { + it('should delete iDevice using endpoint with replaced placeholder', async () => { + mockHttpClient.delete.mockResolvedValue({ responseMessage: 'OK' }); + + const result = await adapter.deleteIdevice('idevice-123'); + + expect(mockHttpClient.delete).toHaveBeenCalledWith('/api/idevice/idevice-123'); + expect(result).toEqual({ responseMessage: 'OK' }); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerContentAdapter(mockHttpClient, {}); + mockHttpClient.delete.mockResolvedValue({ responseMessage: 'OK' }); + + await adapterWithoutEndpoints.deleteIdevice('idevice-123'); + + expect(mockHttpClient.delete).toHaveBeenCalledWith('/api/idevice/idevice-123'); + }); + }); + + describe('send', () => { + it('should throw if endpoint not found', async () => { + await expect(adapter.send('nonexistent', {})).rejects.toThrow('Endpoint not found'); + }); + + it('should use GET method', async () => { + adapter.endpoints.test_get = { path: '/api/test', method: 'GET' }; + mockHttpClient.get.mockResolvedValue({ data: 'test' }); + + const result = await adapter.send('test_get', {}); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/test'); + expect(result).toEqual({ data: 'test' }); + }); + + it('should use POST method', async () => { + adapter.endpoints.test_post = { path: '/api/test', method: 'POST' }; + mockHttpClient.post.mockResolvedValue({ data: 'test' }); + + const result = await adapter.send('test_post', { foo: 'bar' }); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/test', { foo: 'bar' }); + expect(result).toEqual({ data: 'test' }); + }); + + it('should use PUT method', async () => { + adapter.endpoints.test_put = { path: '/api/test', method: 'PUT' }; + mockHttpClient.put.mockResolvedValue({ data: 'test' }); + + const result = await adapter.send('test_put', { foo: 'bar' }); + + expect(mockHttpClient.put).toHaveBeenCalledWith('/api/test', { foo: 'bar' }); + expect(result).toEqual({ data: 'test' }); + }); + + it('should use DELETE method', async () => { + adapter.endpoints.test_delete = { path: '/api/test', method: 'DELETE' }; + mockHttpClient.delete.mockResolvedValue({ data: 'test' }); + + const result = await adapter.send('test_delete', {}); + + expect(mockHttpClient.delete).toHaveBeenCalledWith('/api/test'); + expect(result).toEqual({ data: 'test' }); + }); + + it('should default to POST for unknown methods', async () => { + adapter.endpoints.test_unknown = { path: '/api/test', method: 'PATCH' }; + mockHttpClient.post.mockResolvedValue({ data: 'test' }); + + const result = await adapter.send('test_unknown', { foo: 'bar' }); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/test', { foo: 'bar' }); + expect(result).toEqual({ data: 'test' }); + }); + + it('should use methods array if method not specified', async () => { + adapter.endpoints.test_methods = { path: '/api/test', methods: ['POST'] }; + mockHttpClient.post.mockResolvedValue({ data: 'test' }); + + const result = await adapter.send('test_methods', {}); + + expect(mockHttpClient.post).toHaveBeenCalled(); + expect(result).toEqual({ data: 'test' }); + }); + + it('should default to GET if no method specified', async () => { + adapter.endpoints.test_nomethod = { path: '/api/test' }; + mockHttpClient.get.mockResolvedValue({ data: 'test' }); + + const result = await adapter.send('test_nomethod', {}); + + expect(mockHttpClient.get).toHaveBeenCalled(); + expect(result).toEqual({ data: 'test' }); + }); + }); +}); diff --git a/public/app/core/adapters/server/ServerExportAdapter.js b/public/app/core/adapters/server/ServerExportAdapter.js new file mode 100644 index 000000000..fe13c3145 --- /dev/null +++ b/public/app/core/adapters/server/ServerExportAdapter.js @@ -0,0 +1,138 @@ +/** + * ServerExportAdapter - Server-side implementation of ExportPort. + * Handles export operations via HTTP API. + */ +import { ExportPort } from '../../ports/ExportPort.js'; + +export class ServerExportAdapter extends ExportPort { + /** + * @param {import('../../HttpClient').HttpClient} httpClient + * @param {Object} endpoints - API endpoints + * @param {string} basePath - API base path + */ + constructor(httpClient, endpoints = {}, basePath = '') { + super(); + this.http = httpClient; + this.endpoints = endpoints; + this.basePath = basePath; + } + + /** + * Get endpoint URL by name. + * @private + */ + _getEndpoint(name) { + return this.endpoints[name]?.path || null; + } + + /** + * @inheritdoc + */ + async exportAs(format, projectData, options = {}) { + const url = `${this.basePath}/api/export/${format}`; + return this.http.post(url, { projectData, ...options }); + } + + /** + * @inheritdoc + */ + async getSupportedFormats() { + const formats = [ + { id: 'html5', name: 'Website (HTML5)', extension: 'zip' }, + { id: 'scorm12', name: 'SCORM 1.2', extension: 'zip' }, + { id: 'scorm2004', name: 'SCORM 2004', extension: 'zip' }, + { id: 'ims', name: 'IMS Content Package', extension: 'zip' }, + { id: 'epub3', name: 'ePub 3', extension: 'epub' }, + { id: 'xliff', name: 'XLIFF', extension: 'xliff' }, + ]; + return formats; + } + + /** + * @inheritdoc + */ + async isFormatSupported(format) { + const formats = await this.getSupportedFormats(); + return formats.some(f => f.id === format); + } + + /** + * @inheritdoc + */ + async generatePreview(projectData) { + const url = `${this.basePath}/api/preview/generate`; + return this.http.post(url, projectData); + } + + /** + * @inheritdoc + */ + async exportAsElpx(projectData, assets) { + const url = `${this.basePath}/api/export/elpx`; + return this.http.post(url, { projectData, assets }); + } + + /** + * Export and download in specified format. + * @param {string} projectId + * @param {string} format + * @returns {Promise} + */ + async downloadExport(projectId, format) { + const url = `${this.basePath}/api/export/${projectId}/${format}/download`; + return this.http.downloadBlob(url); + } + + /** + * Get export status (for async exports). + * @param {string} exportId + * @returns {Promise<{status: string, progress: number}>} + */ + async getExportStatus(exportId) { + const url = `${this.basePath}/api/export/status/${exportId}`; + return this.http.get(url); + } + + /** + * Cancel an ongoing export. + * @param {string} exportId + * @returns {Promise} + */ + async cancelExport(exportId) { + const url = `${this.basePath}/api/export/cancel/${exportId}`; + return this.http.post(url, {}); + } + + /** + * Get preview URL for a session. + * @inheritdoc + */ + async getPreviewUrl(sessionId) { + let url = this._getEndpoint('api_ode_export_preview'); + if (!url) { + url = `${this.basePath}/api/ode/${sessionId}/preview`; + } else { + url = url.replace('{odeSessionId}', sessionId); + } + return this.http.get(url); + } + + /** + * Download iDevice/block content as file. + * @inheritdoc + */ + async downloadIDevice(sessionId, blockId, ideviceId) { + let url = this._getEndpoint('api_idevices_download_ode_components'); + if (!url) { + url = `${this.basePath}/api/ode/${sessionId}/block/${blockId}/idevice/${ideviceId}/download`; + } else { + url = url.replace('{odeSessionId}', sessionId); + url = url.replace('{odeBlockId}', blockId); + url = url.replace('{odeIdeviceId}', ideviceId); + } + const response = await this.http.getText(url); + return { url, response }; + } +} + +export default ServerExportAdapter; diff --git a/public/app/core/adapters/server/ServerExportAdapter.test.js b/public/app/core/adapters/server/ServerExportAdapter.test.js new file mode 100644 index 000000000..52f7292b1 --- /dev/null +++ b/public/app/core/adapters/server/ServerExportAdapter.test.js @@ -0,0 +1,219 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ServerExportAdapter } from './ServerExportAdapter.js'; + +describe('ServerExportAdapter', () => { + let adapter; + let mockHttpClient; + let mockEndpoints; + + beforeEach(() => { + mockHttpClient = { + baseUrl: 'http://localhost:8083', + get: vi.fn(), + post: vi.fn(), + downloadBlob: vi.fn(), + getText: vi.fn(), + }; + + mockEndpoints = { + api_ode_export_preview: { path: '/api/ode/{odeSessionId}/preview' }, + api_idevices_download_ode_components: { + path: '/api/ode/{odeSessionId}/block/{odeBlockId}/idevice/{odeIdeviceId}/download', + }, + }; + + adapter = new ServerExportAdapter(mockHttpClient, mockEndpoints, '/test'); + }); + + describe('constructor', () => { + it('should store httpClient, endpoints, and basePath', () => { + expect(adapter.http).toBe(mockHttpClient); + expect(adapter.endpoints).toBe(mockEndpoints); + expect(adapter.basePath).toBe('/test'); + }); + + it('should default endpoints to empty object', () => { + const adapterWithoutEndpoints = new ServerExportAdapter(mockHttpClient); + expect(adapterWithoutEndpoints.endpoints).toEqual({}); + }); + + it('should default basePath to empty string', () => { + const adapterWithoutPath = new ServerExportAdapter(mockHttpClient, {}); + expect(adapterWithoutPath.basePath).toBe(''); + }); + }); + + describe('_getEndpoint', () => { + it('should return endpoint path if exists', () => { + expect(adapter._getEndpoint('api_ode_export_preview')).toBe('/api/ode/{odeSessionId}/preview'); + }); + + it('should return null if endpoint not found', () => { + expect(adapter._getEndpoint('nonexistent')).toBeNull(); + }); + }); + + describe('exportAs', () => { + it('should export in specified format', async () => { + mockHttpClient.post.mockResolvedValue({ url: 'download-url' }); + + const result = await adapter.exportAs('html5', { title: 'Test' }, { option: 'value' }); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/test/api/export/html5', + { projectData: { title: 'Test' }, option: 'value' }, + ); + expect(result).toEqual({ url: 'download-url' }); + }); + }); + + describe('getSupportedFormats', () => { + it('should return list of supported formats', async () => { + const formats = await adapter.getSupportedFormats(); + + expect(formats).toBeInstanceOf(Array); + expect(formats.length).toBe(6); + expect(formats.some(f => f.id === 'html5')).toBe(true); + expect(formats.some(f => f.id === 'scorm12')).toBe(true); + expect(formats.some(f => f.id === 'scorm2004')).toBe(true); + expect(formats.some(f => f.id === 'ims')).toBe(true); + expect(formats.some(f => f.id === 'epub3')).toBe(true); + expect(formats.some(f => f.id === 'xliff')).toBe(true); + }); + + it('should include name and extension for each format', async () => { + const formats = await adapter.getSupportedFormats(); + + formats.forEach(format => { + expect(format).toHaveProperty('id'); + expect(format).toHaveProperty('name'); + expect(format).toHaveProperty('extension'); + }); + }); + }); + + describe('isFormatSupported', () => { + it('should return true for supported format', async () => { + const result = await adapter.isFormatSupported('html5'); + expect(result).toBe(true); + }); + + it('should return false for unsupported format', async () => { + const result = await adapter.isFormatSupported('unknown'); + expect(result).toBe(false); + }); + }); + + describe('generatePreview', () => { + it('should generate preview', async () => { + mockHttpClient.post.mockResolvedValue({ html: '' }); + + const result = await adapter.generatePreview({ title: 'Test' }); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/test/api/preview/generate', + { title: 'Test' }, + ); + expect(result).toEqual({ html: '' }); + }); + }); + + describe('exportAsElpx', () => { + it('should export as ELPX', async () => { + mockHttpClient.post.mockResolvedValue({ blob: 'data' }); + + const result = await adapter.exportAsElpx({ title: 'Test' }, { assets: [] }); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/test/api/export/elpx', + { projectData: { title: 'Test' }, assets: { assets: [] } }, + ); + expect(result).toEqual({ blob: 'data' }); + }); + }); + + describe('downloadExport', () => { + it('should download export as blob', async () => { + const mockBlob = new Blob(['data']); + mockHttpClient.downloadBlob.mockResolvedValue(mockBlob); + + const result = await adapter.downloadExport('project-123', 'html5'); + + expect(mockHttpClient.downloadBlob).toHaveBeenCalledWith( + '/test/api/export/project-123/html5/download', + ); + expect(result).toBe(mockBlob); + }); + }); + + describe('getExportStatus', () => { + it('should get export status', async () => { + mockHttpClient.get.mockResolvedValue({ status: 'complete', progress: 100 }); + + const result = await adapter.getExportStatus('export-123'); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/test/api/export/status/export-123'); + expect(result).toEqual({ status: 'complete', progress: 100 }); + }); + }); + + describe('cancelExport', () => { + it('should cancel export', async () => { + mockHttpClient.post.mockResolvedValue({}); + + await adapter.cancelExport('export-123'); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/test/api/export/cancel/export-123', + {}, + ); + }); + }); + + describe('getPreviewUrl', () => { + it('should use endpoint if available', async () => { + mockHttpClient.get.mockResolvedValue({ previewUrl: 'http://preview' }); + + const result = await adapter.getPreviewUrl('session-123'); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/ode/session-123/preview'); + expect(result).toEqual({ previewUrl: 'http://preview' }); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerExportAdapter(mockHttpClient, {}, '/test'); + mockHttpClient.get.mockResolvedValue({ previewUrl: 'http://preview' }); + + await adapterWithoutEndpoints.getPreviewUrl('session-123'); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/test/api/ode/session-123/preview'); + }); + }); + + describe('downloadIDevice', () => { + it('should use endpoint with replaced placeholders', async () => { + mockHttpClient.getText.mockResolvedValue('
content
'); + + const result = await adapter.downloadIDevice('session-123', 'block-456', 'idevice-789'); + + expect(mockHttpClient.getText).toHaveBeenCalledWith( + '/api/ode/session-123/block/block-456/idevice/idevice-789/download', + ); + expect(result).toEqual({ + url: '/api/ode/session-123/block/block-456/idevice/idevice-789/download', + response: '
content
', + }); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerExportAdapter(mockHttpClient, {}, '/test'); + mockHttpClient.getText.mockResolvedValue('content'); + + await adapterWithoutEndpoints.downloadIDevice('s', 'b', 'i'); + + expect(mockHttpClient.getText).toHaveBeenCalledWith( + '/test/api/ode/s/block/b/idevice/i/download', + ); + }); + }); +}); diff --git a/public/app/core/adapters/server/ServerLinkValidationAdapter.js b/public/app/core/adapters/server/ServerLinkValidationAdapter.js new file mode 100644 index 000000000..7cf4b3ed8 --- /dev/null +++ b/public/app/core/adapters/server/ServerLinkValidationAdapter.js @@ -0,0 +1,113 @@ +/** + * ServerLinkValidationAdapter - Server-side implementation of LinkValidationPort. + * Handles link validation via HTTP API. + */ +import { LinkValidationPort } from '../../ports/LinkValidationPort.js'; + +export class ServerLinkValidationAdapter extends LinkValidationPort { + /** + * @param {import('../../HttpClient').HttpClient} httpClient + * @param {Object} endpoints - API endpoints from parameters + * @param {string} basePath - API base path + */ + constructor(httpClient, endpoints = {}, basePath = '') { + super(); + this.http = httpClient; + this.endpoints = endpoints; + this.basePath = basePath; + } + + /** + * Get endpoint URL by name. + * @private + */ + _getEndpoint(name) { + return this.endpoints[name]?.path || null; + } + + /** + * @inheritdoc + */ + async getSessionBrokenLinks(params) { + const url = this._getEndpoint('api_odes_session_get_broken_links'); + if (!url) { + const baseUrl = window.eXeLearning?.config?.baseURL || ''; + return this.http.post( + `${baseUrl}${this.basePath}/api/ode-management/odes/session/brokenlinks`, + params + ); + } + return this.http.post(url, params); + } + + /** + * @inheritdoc + */ + async extractLinks(params) { + const baseUrl = window.eXeLearning?.config?.baseURL || ''; + const url = `${baseUrl}${this.basePath}/api/ode-management/odes/session/brokenlinks/extract`; + return this.http.post(url, params); + } + + /** + * @inheritdoc + */ + getValidationStreamUrl() { + const baseUrl = window.eXeLearning?.config?.baseURL || ''; + return `${baseUrl}${this.basePath}/api/ode-management/odes/session/brokenlinks/validate-stream`; + } + + /** + * @inheritdoc + */ + async getPageBrokenLinks(pageId) { + const url = this._getEndpoint('api_odes_pag_get_broken_links'); + if (!url) { + const baseUrl = window.eXeLearning?.config?.baseURL || ''; + return this.http.get( + `${baseUrl}${this.basePath}/api/ode-management/odes/page/${pageId}/brokenlinks` + ); + } + const pageUrl = url.replace('{odePageId}', pageId); + return this.http.get(pageUrl); + } + + /** + * @inheritdoc + */ + async getBlockBrokenLinks(blockId) { + const url = this._getEndpoint('api_odes_block_get_broken_links'); + if (!url) { + const baseUrl = window.eXeLearning?.config?.baseURL || ''; + return this.http.get( + `${baseUrl}${this.basePath}/api/ode-management/odes/block/${blockId}/brokenlinks` + ); + } + const blockUrl = url.replace('{odeBlockId}', blockId); + return this.http.get(blockUrl); + } + + /** + * @inheritdoc + */ + async getIdeviceBrokenLinks(ideviceId) { + const url = this._getEndpoint('api_odes_idevice_get_broken_links'); + if (!url) { + const baseUrl = window.eXeLearning?.config?.baseURL || ''; + return this.http.get( + `${baseUrl}${this.basePath}/api/ode-management/odes/idevice/${ideviceId}/brokenlinks` + ); + } + const ideviceUrl = url.replace('{odeIdeviceId}', ideviceId); + return this.http.get(ideviceUrl); + } + + /** + * @inheritdoc + */ + isSupported() { + return true; + } +} + +export default ServerLinkValidationAdapter; diff --git a/public/app/core/adapters/server/ServerLinkValidationAdapter.test.js b/public/app/core/adapters/server/ServerLinkValidationAdapter.test.js new file mode 100644 index 000000000..1f144c8dc --- /dev/null +++ b/public/app/core/adapters/server/ServerLinkValidationAdapter.test.js @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ServerLinkValidationAdapter } from './ServerLinkValidationAdapter.js'; + +describe('ServerLinkValidationAdapter', () => { + let adapter; + let mockHttpClient; + let mockEndpoints; + + beforeEach(() => { + mockHttpClient = { + get: vi.fn(), + post: vi.fn(), + }; + + mockEndpoints = { + api_odes_session_get_broken_links: { path: '/api/odes/session/brokenlinks' }, + api_odes_pag_get_broken_links: { path: '/api/odes/page/{odePageId}/brokenlinks' }, + api_odes_block_get_broken_links: { path: '/api/odes/block/{odeBlockId}/brokenlinks' }, + api_odes_idevice_get_broken_links: { path: '/api/odes/idevice/{odeIdeviceId}/brokenlinks' }, + }; + + adapter = new ServerLinkValidationAdapter(mockHttpClient, mockEndpoints, '/test'); + + window.eXeLearning = { + config: { baseURL: 'http://localhost:8083' }, + }; + }); + + afterEach(() => { + delete window.eXeLearning; + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should store httpClient, endpoints and basePath', () => { + expect(adapter.http).toBe(mockHttpClient); + expect(adapter.endpoints).toBe(mockEndpoints); + expect(adapter.basePath).toBe('/test'); + }); + + it('should default endpoints to empty object', () => { + const adapterWithoutEndpoints = new ServerLinkValidationAdapter(mockHttpClient); + expect(adapterWithoutEndpoints.endpoints).toEqual({}); + }); + + it('should default basePath to empty string', () => { + const adapterWithoutPath = new ServerLinkValidationAdapter(mockHttpClient, {}); + expect(adapterWithoutPath.basePath).toBe(''); + }); + }); + + describe('_getEndpoint', () => { + it('should return endpoint path if exists', () => { + expect(adapter._getEndpoint('api_odes_session_get_broken_links')) + .toBe('/api/odes/session/brokenlinks'); + }); + + it('should return null if endpoint not found', () => { + expect(adapter._getEndpoint('nonexistent')).toBeNull(); + }); + }); + + describe('getSessionBrokenLinks', () => { + it('should use endpoint if available', async () => { + const params = { sessionId: '123' }; + mockHttpClient.post.mockResolvedValue({ brokenLinks: [] }); + + const result = await adapter.getSessionBrokenLinks(params); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/api/odes/session/brokenlinks', + params + ); + expect(result.brokenLinks).toEqual([]); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerLinkValidationAdapter( + mockHttpClient, {}, '/test' + ); + const params = { sessionId: '123' }; + mockHttpClient.post.mockResolvedValue({ brokenLinks: [] }); + + await adapterWithoutEndpoints.getSessionBrokenLinks(params); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + 'http://localhost:8083/test/api/ode-management/odes/session/brokenlinks', + params + ); + }); + }); + + describe('extractLinks', () => { + it('should call correct URL', async () => { + const params = { sessionId: '123', content: 'Test' }; + mockHttpClient.post.mockResolvedValue({ links: [], totalLinks: 0 }); + + const result = await adapter.extractLinks(params); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + 'http://localhost:8083/test/api/ode-management/odes/session/brokenlinks/extract', + params + ); + expect(result.links).toEqual([]); + }); + }); + + describe('getValidationStreamUrl', () => { + it('should return correct stream URL', () => { + const url = adapter.getValidationStreamUrl(); + + expect(url).toBe( + 'http://localhost:8083/test/api/ode-management/odes/session/brokenlinks/validate-stream' + ); + }); + + it('should handle missing baseURL', () => { + delete window.eXeLearning; + const url = adapter.getValidationStreamUrl(); + + expect(url).toBe('/test/api/ode-management/odes/session/brokenlinks/validate-stream'); + }); + }); + + describe('getPageBrokenLinks', () => { + it('should use endpoint with pageId substitution', async () => { + mockHttpClient.get.mockResolvedValue({ brokenLinks: [] }); + + await adapter.getPageBrokenLinks('page-123'); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + '/api/odes/page/page-123/brokenlinks' + ); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerLinkValidationAdapter( + mockHttpClient, {}, '/test' + ); + mockHttpClient.get.mockResolvedValue({ brokenLinks: [] }); + + await adapterWithoutEndpoints.getPageBrokenLinks('page-123'); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'http://localhost:8083/test/api/ode-management/odes/page/page-123/brokenlinks' + ); + }); + }); + + describe('getBlockBrokenLinks', () => { + it('should use endpoint with blockId substitution', async () => { + mockHttpClient.get.mockResolvedValue({ brokenLinks: [] }); + + await adapter.getBlockBrokenLinks('block-456'); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + '/api/odes/block/block-456/brokenlinks' + ); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerLinkValidationAdapter( + mockHttpClient, {}, '/test' + ); + mockHttpClient.get.mockResolvedValue({ brokenLinks: [] }); + + await adapterWithoutEndpoints.getBlockBrokenLinks('block-456'); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'http://localhost:8083/test/api/ode-management/odes/block/block-456/brokenlinks' + ); + }); + }); + + describe('getIdeviceBrokenLinks', () => { + it('should use endpoint with ideviceId substitution', async () => { + mockHttpClient.get.mockResolvedValue({ brokenLinks: [] }); + + await adapter.getIdeviceBrokenLinks('idevice-789'); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + '/api/odes/idevice/idevice-789/brokenlinks' + ); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerLinkValidationAdapter( + mockHttpClient, {}, '/test' + ); + mockHttpClient.get.mockResolvedValue({ brokenLinks: [] }); + + await adapterWithoutEndpoints.getIdeviceBrokenLinks('idevice-789'); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'http://localhost:8083/test/api/ode-management/odes/idevice/idevice-789/brokenlinks' + ); + }); + }); + + describe('isSupported', () => { + it('should return true', () => { + expect(adapter.isSupported()).toBe(true); + }); + }); +}); diff --git a/public/app/core/adapters/server/ServerPlatformIntegrationAdapter.js b/public/app/core/adapters/server/ServerPlatformIntegrationAdapter.js new file mode 100644 index 000000000..556b31b0d --- /dev/null +++ b/public/app/core/adapters/server/ServerPlatformIntegrationAdapter.js @@ -0,0 +1,56 @@ +/** + * ServerPlatformIntegrationAdapter - Server-side implementation of PlatformIntegrationPort. + * Handles LMS platform integration via HTTP API. + */ +import { PlatformIntegrationPort } from '../../ports/PlatformIntegrationPort.js'; + +export class ServerPlatformIntegrationAdapter extends PlatformIntegrationPort { + /** + * @param {import('../../HttpClient').HttpClient} httpClient + * @param {Object} endpoints - API endpoints from parameters + */ + constructor(httpClient, endpoints = {}) { + super(); + this.http = httpClient; + this.endpoints = endpoints; + } + + /** + * Get endpoint URL by name. + * @private + */ + _getEndpoint(name) { + return this.endpoints[name]?.path || null; + } + + /** + * @inheritdoc + */ + async uploadElp(params) { + const url = this._getEndpoint('set_platform_new_ode'); + if (!url) { + return { responseMessage: 'ENDPOINT_NOT_CONFIGURED' }; + } + return this.http.post(url, params); + } + + /** + * @inheritdoc + */ + async openElp(params) { + const url = this._getEndpoint('open_platform_elp'); + if (!url) { + return { responseMessage: 'ENDPOINT_NOT_CONFIGURED' }; + } + return this.http.post(url, params); + } + + /** + * @inheritdoc + */ + isSupported() { + return true; + } +} + +export default ServerPlatformIntegrationAdapter; diff --git a/public/app/core/adapters/server/ServerPlatformIntegrationAdapter.test.js b/public/app/core/adapters/server/ServerPlatformIntegrationAdapter.test.js new file mode 100644 index 000000000..d3d432782 --- /dev/null +++ b/public/app/core/adapters/server/ServerPlatformIntegrationAdapter.test.js @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ServerPlatformIntegrationAdapter } from './ServerPlatformIntegrationAdapter.js'; + +describe('ServerPlatformIntegrationAdapter', () => { + let adapter; + let mockHttpClient; + let mockEndpoints; + + beforeEach(() => { + mockHttpClient = { + post: vi.fn(), + }; + + mockEndpoints = { + set_platform_new_ode: { path: '/api/platform/ode/new' }, + open_platform_elp: { path: '/api/platform/elp/open' }, + }; + + adapter = new ServerPlatformIntegrationAdapter(mockHttpClient, mockEndpoints); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should store httpClient and endpoints', () => { + expect(adapter.http).toBe(mockHttpClient); + expect(adapter.endpoints).toBe(mockEndpoints); + }); + + it('should default endpoints to empty object', () => { + const adapterWithoutEndpoints = new ServerPlatformIntegrationAdapter(mockHttpClient); + expect(adapterWithoutEndpoints.endpoints).toEqual({}); + }); + }); + + describe('_getEndpoint', () => { + it('should return endpoint path if exists', () => { + expect(adapter._getEndpoint('set_platform_new_ode')) + .toBe('/api/platform/ode/new'); + }); + + it('should return null if endpoint not found', () => { + expect(adapter._getEndpoint('nonexistent')).toBeNull(); + }); + }); + + describe('uploadElp', () => { + it('should call http.post with correct endpoint and params', async () => { + const params = { elpData: 'base64data', filename: 'project.elp' }; + mockHttpClient.post.mockResolvedValue({ success: true, url: 'https://lms.example.com/resource/123' }); + + const result = await adapter.uploadElp(params); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/platform/ode/new', params); + expect(result.success).toBe(true); + expect(result.url).toBe('https://lms.example.com/resource/123'); + }); + + it('should return ENDPOINT_NOT_CONFIGURED if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerPlatformIntegrationAdapter(mockHttpClient, {}); + + const result = await adapterWithoutEndpoints.uploadElp({}); + + expect(result).toEqual({ responseMessage: 'ENDPOINT_NOT_CONFIGURED' }); + expect(mockHttpClient.post).not.toHaveBeenCalled(); + }); + }); + + describe('openElp', () => { + it('should call http.post with correct endpoint and params', async () => { + const params = { resourceId: '123', platform: 'moodle' }; + mockHttpClient.post.mockResolvedValue({ success: true, elpData: 'base64data' }); + + const result = await adapter.openElp(params); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/platform/elp/open', params); + expect(result.success).toBe(true); + expect(result.elpData).toBe('base64data'); + }); + + it('should return ENDPOINT_NOT_CONFIGURED if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerPlatformIntegrationAdapter(mockHttpClient, {}); + + const result = await adapterWithoutEndpoints.openElp({}); + + expect(result).toEqual({ responseMessage: 'ENDPOINT_NOT_CONFIGURED' }); + expect(mockHttpClient.post).not.toHaveBeenCalled(); + }); + }); + + describe('isSupported', () => { + it('should return true', () => { + expect(adapter.isSupported()).toBe(true); + }); + }); +}); diff --git a/public/app/core/adapters/server/ServerProjectRepository.js b/public/app/core/adapters/server/ServerProjectRepository.js new file mode 100644 index 000000000..1358188aa --- /dev/null +++ b/public/app/core/adapters/server/ServerProjectRepository.js @@ -0,0 +1,464 @@ +/** + * ServerProjectRepository - Server-side implementation of ProjectRepositoryPort. + * Handles project CRUD operations via HTTP API. + */ +import { ProjectRepositoryPort } from '../../ports/ProjectRepositoryPort.js'; +import { NetworkError, NotFoundError } from '../../errors.js'; + +export class ServerProjectRepository extends ProjectRepositoryPort { + /** + * @param {import('../../HttpClient').HttpClient} httpClient + * @param {string} basePath - API base path (e.g., '/web/exelearning') + */ + constructor(httpClient, basePath = '') { + super(); + this.http = httpClient; + this.basePath = basePath; + } + + /** + * Get auth token from available sources. + * @private + */ + _getAuthToken() { + return ( + window.eXeLearning?.app?.project?._yjsBridge?.authToken || + window.eXeLearning?.app?.auth?.getToken?.() || + window.eXeLearning?.config?.token || + localStorage.getItem('authToken') + ); + } + + /** + * Make authenticated request. + * @private + */ + async _authFetch(url, options = {}) { + const token = this._getAuthToken(); + const headers = { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }; + + const response = await fetch(url, { + ...options, + headers, + credentials: 'include', + }); + + if (!response.ok) { + if (response.status === 404) { + throw new NotFoundError('project', url); + } + throw new NetworkError( + `Request failed: ${response.statusText}`, + response.status + ); + } + + if (response.status === 204) { + return null; + } + + return response.json(); + } + + /** + * @inheritdoc + */ + async list() { + const url = `${this.http.baseUrl}${this.basePath}/api/projects/user/list`; + + try { + const result = await this._authFetch(url, { method: 'GET' }); + // Transform response to consistent format + return result?.odeFiles?.odeFilesSync || []; + } catch (error) { + console.error('[ServerProjectRepository] list error:', error); + return []; + } + } + + /** + * @inheritdoc + */ + async get(id) { + const url = `${this.http.baseUrl}${this.basePath}/api/projects/${id}`; + + try { + return await this._authFetch(url, { method: 'GET' }); + } catch (error) { + if (error instanceof NotFoundError) { + return null; + } + throw error; + } + } + + /** + * @inheritdoc + */ + async create(data) { + const url = `${this.http.baseUrl}${this.basePath}/api/project/create-quick`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify(data), + }); + } + + /** + * @inheritdoc + */ + async update(id, data) { + const url = `${this.http.baseUrl}${this.basePath}/api/projects/${id}`; + return this._authFetch(url, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + /** + * @inheritdoc + */ + async delete(id) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/remove-file`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify({ odeFileId: id }), + }); + } + + /** + * @inheritdoc + */ + async getRecent(limit = 3) { + const url = `${this.http.baseUrl}${this.basePath}/api/projects/user/recent`; + + try { + return await this._authFetch(url, { method: 'GET' }); + } catch (error) { + console.error('[ServerProjectRepository] getRecent error:', error); + return []; + } + } + + /** + * @inheritdoc + */ + async exists(id) { + try { + const project = await this.get(id); + return project !== null; + } catch { + return false; + } + } + + /** + * Join a project session. + * @param {string} sessionId + * @returns {Promise<{available: boolean}>} + */ + async joinSession(sessionId) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/current-users/check-ode-session-id`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify({ odeSessionId: sessionId }), + }); + } + + /** + * Check current users in a session. + * @param {Object} params + * @returns {Promise<{currentUsers: number}>} + */ + async checkCurrentUsers(params) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/check-before-leave-ode-session`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify(params), + }); + } + + /** + * @inheritdoc + */ + async save(sessionId, params) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/ode/${sessionId}/save/manual`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify(params), + }); + } + + /** + * @inheritdoc + */ + async autoSave(sessionId, params) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/ode/${sessionId}/save/auto`; + // Fire and forget for autosave + this._authFetch(url, { + method: 'POST', + body: JSON.stringify(params), + }).catch((error) => { + console.warn('[ServerProjectRepository] autoSave error:', error); + }); + } + + /** + * @inheritdoc + */ + async saveAs(sessionId, params) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/ode/${sessionId}/save-as`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify(params), + }); + } + + /** + * @inheritdoc + */ + async duplicate(id) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/duplicate`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify({ odeFileId: id }), + }); + } + + /** + * @inheritdoc + */ + async getLastUpdated(id) { + // Use the endpoint without project ID - server returns current timestamp + const url = `${this.http.baseUrl}${this.basePath}/api/odes/last-updated`; + try { + return await this._authFetch(url, { method: 'GET' }); + } catch (error) { + console.error('[ServerProjectRepository] getLastUpdated error:', error); + return { lastUpdated: null }; + } + } + + /** + * @inheritdoc + */ + async getConcurrentUsers(id, versionId, sessionId) { + const url = `${this.http.baseUrl}${this.basePath}/api/odes/current-users?odeSessionId=${encodeURIComponent(sessionId)}`; + try { + const result = await this._authFetch(url); + // Map backend response to expected format + return { users: result.currentUsers || [] }; + } catch (error) { + console.error('[ServerProjectRepository] getConcurrentUsers error:', error); + return { users: [] }; + } + } + + /** + * @inheritdoc + */ + async closeSession(params) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/close-session`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify(params), + }); + } + + /** + * @inheritdoc + */ + async openFile(fileName) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/ode/elp/open`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify(fileName), + }); + } + + /** + * @inheritdoc + */ + async openLocalFile(data) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/ode/local/elp/open`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify(data), + }); + } + + /** + * @inheritdoc + */ + async openLargeLocalFile(data) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/ode/local/large-elp/open`; + // Large file uploads use FormData + const token = this._getAuthToken(); + const response = await fetch(url, { + method: 'POST', + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + credentials: 'include', + body: data, // FormData + }); + + if (!response.ok) { + throw new NetworkError( + `Request failed: ${response.statusText}`, + response.status + ); + } + return response.json(); + } + + /** + * @inheritdoc + */ + async getLocalProperties(data) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/ode/local/xml/properties/open`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify(data), + }); + } + + /** + * @inheritdoc + */ + async getLocalComponents(data) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/ode/local/idevices/open`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify(data), + }); + } + + /** + * @inheritdoc + */ + async importToRoot(data) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/ode/local/elp/import/root`; + const token = this._getAuthToken(); + const response = await fetch(url, { + method: 'POST', + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + credentials: 'include', + body: data, // FormData + }); + + if (!response.ok) { + throw new NetworkError( + `Request failed: ${response.statusText}`, + response.status + ); + } + return response.json(); + } + + /** + * @inheritdoc + */ + async importToRootFromLocal(payload) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/ode/import/local/root`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify(payload), + }); + } + + /** + * @inheritdoc + */ + async importAsChild(navId, payload) { + const url = `${this.http.baseUrl}${this.basePath}/api/nav-structure-management/nav-structures/${navId}/import-elp`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify(payload), + }); + } + + /** + * @inheritdoc + */ + async openMultipleLocalFiles(data) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/ode/multiple/local/elp/open`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify(data), + }); + } + + /** + * @inheritdoc + */ + async deleteByDate(params) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/remove-date-files`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify(params), + }); + } + + /** + * @inheritdoc + */ + async cleanAutosaves(params) { + const url = `${this.http.baseUrl}${this.basePath}/api/odes/clean-init-autosave`; + try { + return await this._authFetch(url, { + method: 'POST', + body: JSON.stringify(params), + }); + } catch (error) { + // Autosave cleanup is not critical - fail silently + console.warn('[ServerProjectRepository] cleanAutosaves error:', error); + return { success: true, message: 'Cleanup skipped' }; + } + } + + /** + * @inheritdoc + */ + async getStructure(versionId, sessionId) { + const url = `${this.http.baseUrl}${this.basePath}/api/nav-structure-management/nav-structures/${versionId}/${sessionId}`; + return this._authFetch(url, { method: 'GET' }); + } + + /** + * @inheritdoc + */ + async getProperties(sessionId) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/properties/${sessionId}`; + return this._authFetch(url, { method: 'GET' }); + } + + /** + * @inheritdoc + */ + async saveProperties(params) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/properties/save`; + return this._authFetch(url, { + method: 'PUT', + body: JSON.stringify(params), + }); + } + + /** + * @inheritdoc + */ + async getUsedFiles(params) { + const url = `${this.http.baseUrl}${this.basePath}/api/ode-management/odes/session/used-files`; + return this._authFetch(url, { + method: 'POST', + body: JSON.stringify(params), + }); + } +} + +export default ServerProjectRepository; diff --git a/public/app/core/adapters/server/ServerProjectRepository.test.js b/public/app/core/adapters/server/ServerProjectRepository.test.js new file mode 100644 index 000000000..59e50cec4 --- /dev/null +++ b/public/app/core/adapters/server/ServerProjectRepository.test.js @@ -0,0 +1,712 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ServerProjectRepository } from './ServerProjectRepository.js'; + +describe('ServerProjectRepository', () => { + let repo; + let mockHttpClient; + + beforeEach(() => { + mockHttpClient = { + baseUrl: 'http://localhost:8083', + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }; + + repo = new ServerProjectRepository(mockHttpClient, '/test'); + + // Mock fetch + global.fetch = vi.fn(); + + // Mock localStorage + const localStorageData = {}; + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn((key) => localStorageData[key] || null), + setItem: vi.fn((key, value) => { localStorageData[key] = value; }), + removeItem: vi.fn((key) => { delete localStorageData[key]; }), + clear: vi.fn(() => { Object.keys(localStorageData).forEach((k) => delete localStorageData[k]); }), + }, + writable: true, + }); + + // Mock window.eXeLearning for auth token + window.eXeLearning = { + config: { token: 'test-token' }, + }; + }); + + afterEach(() => { + delete window.eXeLearning; + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should store httpClient and basePath', () => { + expect(repo.http).toBe(mockHttpClient); + expect(repo.basePath).toBe('/test'); + }); + + it('should default basePath to empty string', () => { + const repoWithoutPath = new ServerProjectRepository(mockHttpClient); + expect(repoWithoutPath.basePath).toBe(''); + }); + }); + + describe('_getAuthToken', () => { + it('should get token from window.eXeLearning.config', () => { + const token = repo._getAuthToken(); + expect(token).toBe('test-token'); + }); + + it('should fallback to localStorage', () => { + delete window.eXeLearning; + window.localStorage.getItem.mockReturnValue('local-token'); + + const token = repo._getAuthToken(); + expect(token).toBe('local-token'); + }); + + it('should return null if no token available', () => { + delete window.eXeLearning; + window.localStorage.getItem.mockReturnValue(null); + + const token = repo._getAuthToken(); + expect(token).toBeNull(); + }); + }); + + describe('_authFetch', () => { + it('should make authenticated request', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: 'test' }), + }); + + const result = await repo._authFetch('http://test.com/api'); + + expect(global.fetch).toHaveBeenCalledWith('http://test.com/api', { + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer test-token', + }, + credentials: 'include', + }); + expect(result).toEqual({ data: 'test' }); + }); + + it('should return null for 204 status', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 204, + }); + + const result = await repo._authFetch('http://test.com/api'); + expect(result).toBeNull(); + }); + + it('should throw NotFoundError for 404', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + await expect(repo._authFetch('http://test.com/api')).rejects.toThrow(); + }); + + it('should throw NetworkError for other errors', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Server Error', + }); + + await expect(repo._authFetch('http://test.com/api')).rejects.toThrow(); + }); + }); + + describe('list', () => { + it('should return project list', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ + odeFiles: { + odeFilesSync: [{ id: '1', title: 'Project 1' }], + }, + }), + }); + + const result = await repo.list(); + + expect(result).toEqual([{ id: '1', title: 'Project 1' }]); + }); + + it('should return empty array on error', async () => { + global.fetch.mockRejectedValue(new Error('Network error')); + + const result = await repo.list(); + + expect(result).toEqual([]); + }); + }); + + describe('get', () => { + it('should return project by id', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: '123', title: 'Test' }), + }); + + const result = await repo.get('123'); + + expect(result).toEqual({ id: '123', title: 'Test' }); + }); + + it('should return null for not found', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + const result = await repo.get('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('create', () => { + it('should create project with POST request', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ uuid: 'new-uuid', title: 'New Project' }), + }); + + const result = await repo.create({ title: 'New Project' }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/project/create-quick'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ title: 'New Project' }), + }), + ); + expect(result).toEqual({ uuid: 'new-uuid', title: 'New Project' }); + }); + }); + + describe('update', () => { + it('should update project with PUT request', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: '123', title: 'Updated' }), + }); + + const result = await repo.update('123', { title: 'Updated' }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/projects/123'), + expect.objectContaining({ + method: 'PUT', + }), + ); + expect(result).toEqual({ id: '123', title: 'Updated' }); + }); + }); + + describe('delete', () => { + it('should delete project with POST request', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ success: true }), + }); + + await repo.delete('123'); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/ode-management/odes/remove-file'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ odeFileId: '123' }), + }), + ); + }); + }); + + describe('getRecent', () => { + it('should return recent projects', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve([{ id: '1' }, { id: '2' }]), + }); + + const result = await repo.getRecent(3); + + expect(result).toEqual([{ id: '1' }, { id: '2' }]); + }); + + it('should return empty array on error', async () => { + global.fetch.mockRejectedValue(new Error('Error')); + + const result = await repo.getRecent(); + + expect(result).toEqual([]); + }); + }); + + describe('exists', () => { + it('should return true if project exists', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: '123' }), + }); + + const result = await repo.exists('123'); + + expect(result).toBe(true); + }); + + it('should return false if project not found', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + const result = await repo.exists('nonexistent'); + + expect(result).toBe(false); + }); + }); + + describe('joinSession', () => { + it('should join session', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ available: true }), + }); + + const result = await repo.joinSession('session-123'); + + expect(result).toEqual({ available: true }); + }); + }); + + describe('checkCurrentUsers', () => { + it('should check current users', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ currentUsers: 2 }), + }); + + const result = await repo.checkCurrentUsers({ sessionId: 'test' }); + + expect(result).toEqual({ currentUsers: 2 }); + }); + }); + + describe('save', () => { + it('should save project', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ responseMessage: 'OK' }), + }); + + const result = await repo.save('session-123', { data: 'test' }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/ode-management/odes/ode/session-123/save/manual'), + expect.any(Object), + ); + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('autoSave', () => { + it('should autosave without waiting', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ responseMessage: 'OK' }), + }); + + // autoSave doesn't return anything (fire and forget) + await repo.autoSave('session-123', { data: 'test' }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/ode-management/odes/ode/session-123/save/auto'), + expect.any(Object), + ); + }); + }); + + describe('saveAs', () => { + it('should save as new project', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ responseMessage: 'OK', newId: 'new-123' }), + }); + + const result = await repo.saveAs('session-123', { title: 'Copy' }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/ode-management/odes/ode/session-123/save-as'), + expect.any(Object), + ); + expect(result).toEqual({ responseMessage: 'OK', newId: 'new-123' }); + }); + }); + + describe('duplicate', () => { + it('should duplicate project', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ responseMessage: 'OK' }), + }); + + const result = await repo.duplicate('123'); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/ode-management/odes/duplicate'), + expect.objectContaining({ + body: JSON.stringify({ odeFileId: '123' }), + }), + ); + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('getLastUpdated', () => { + it('should get last updated timestamp', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ lastUpdated: '2024-01-01' }), + }); + + const result = await repo.getLastUpdated('123'); + + expect(result).toEqual({ lastUpdated: '2024-01-01' }); + }); + + it('should return null on error', async () => { + global.fetch.mockRejectedValue(new Error('Error')); + + const result = await repo.getLastUpdated('123'); + + expect(result).toEqual({ lastUpdated: null }); + }); + }); + + describe('getConcurrentUsers', () => { + it('should get concurrent users via GET with query param', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ currentUsers: [{ id: '1' }] }), + }); + + const result = await repo.getConcurrentUsers('123', 'v1', 'session-id'); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:8083/test/api/odes/current-users?odeSessionId=session-id', + expect.any(Object) + ); + expect(result).toEqual({ users: [{ id: '1' }] }); + }); + + it('should return empty users when currentUsers is missing', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); + + const result = await repo.getConcurrentUsers('123', 'v1', 'session'); + + expect(result).toEqual({ users: [] }); + }); + + it('should return empty users on error', async () => { + global.fetch.mockRejectedValue(new Error('Error')); + + const result = await repo.getConcurrentUsers('123', 'v1', 'session'); + + expect(result).toEqual({ users: [] }); + }); + }); + + describe('closeSession', () => { + it('should close session', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ responseMessage: 'OK' }), + }); + + const result = await repo.closeSession({ sessionId: 'test' }); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('openFile', () => { + it('should open file', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ odeSessionId: 'new-session' }), + }); + + const result = await repo.openFile('test.elp'); + + expect(result).toEqual({ odeSessionId: 'new-session' }); + }); + }); + + describe('openLocalFile', () => { + it('should open local file', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ odeSessionId: 'local-session' }), + }); + + const result = await repo.openLocalFile({ content: 'test' }); + + expect(result).toEqual({ odeSessionId: 'local-session' }); + }); + }); + + describe('openLargeLocalFile', () => { + it('should open large local file with FormData', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ odeSessionId: 'large-session' }), + }); + + const formData = new FormData(); + const result = await repo.openLargeLocalFile(formData); + + expect(result).toEqual({ odeSessionId: 'large-session' }); + }); + + it('should throw on error', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Error', + }); + + const formData = new FormData(); + await expect(repo.openLargeLocalFile(formData)).rejects.toThrow(); + }); + }); + + describe('getLocalProperties', () => { + it('should get local properties', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ properties: {} }), + }); + + const result = await repo.getLocalProperties({ data: 'test' }); + + expect(result).toEqual({ properties: {} }); + }); + }); + + describe('getLocalComponents', () => { + it('should get local components', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ components: [] }), + }); + + const result = await repo.getLocalComponents({ data: 'test' }); + + expect(result).toEqual({ components: [] }); + }); + }); + + describe('importToRoot', () => { + it('should import to root with FormData', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ responseMessage: 'OK' }), + }); + + const formData = new FormData(); + const result = await repo.importToRoot(formData); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('importToRootFromLocal', () => { + it('should import to root from local', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ responseMessage: 'OK' }), + }); + + const result = await repo.importToRootFromLocal({ data: 'test' }); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('importAsChild', () => { + it('should import as child', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ responseMessage: 'OK' }), + }); + + const result = await repo.importAsChild('nav-123', { data: 'test' }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/nav-structure-management/nav-structures/nav-123/import-elp'), + expect.any(Object), + ); + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('openMultipleLocalFiles', () => { + it('should open multiple local files', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ responseMessage: 'OK' }), + }); + + const result = await repo.openMultipleLocalFiles({ files: [] }); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('deleteByDate', () => { + it('should delete by date', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ responseMessage: 'OK' }), + }); + + const result = await repo.deleteByDate({ before: '2024-01-01' }); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('cleanAutosaves', () => { + it('should clean autosaves', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ responseMessage: 'OK' }), + }); + + const result = await repo.cleanAutosaves({ sessionId: 'test' }); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + + it('should handle errors gracefully', async () => { + global.fetch.mockRejectedValue(new Error('Error')); + + const result = await repo.cleanAutosaves({ sessionId: 'test' }); + + expect(result).toEqual({ success: true, message: 'Cleanup skipped' }); + }); + }); + + describe('getStructure', () => { + it('should get structure', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ structure: {} }), + }); + + const result = await repo.getStructure('v1', 'session'); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/nav-structure-management/nav-structures/v1/session'), + expect.any(Object), + ); + expect(result).toEqual({ structure: {} }); + }); + }); + + describe('getProperties', () => { + it('should get properties', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ properties: {} }), + }); + + const result = await repo.getProperties('session'); + + expect(result).toEqual({ properties: {} }); + }); + }); + + describe('saveProperties', () => { + it('should save properties', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ responseMessage: 'OK' }), + }); + + const result = await repo.saveProperties({ title: 'Test' }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/ode-management/odes/properties/save'), + expect.objectContaining({ + method: 'PUT', + }), + ); + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('getUsedFiles', () => { + it('should get used files', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ files: [] }), + }); + + const result = await repo.getUsedFiles({ sessionId: 'test' }); + + expect(result).toEqual({ files: [] }); + }); + }); +}); diff --git a/public/app/core/adapters/server/ServerSharingAdapter.js b/public/app/core/adapters/server/ServerSharingAdapter.js new file mode 100644 index 000000000..fd02900c7 --- /dev/null +++ b/public/app/core/adapters/server/ServerSharingAdapter.js @@ -0,0 +1,137 @@ +/** + * ServerSharingAdapter - Server-side implementation of SharingPort. + * Handles project sharing operations via HTTP API. + */ +import { SharingPort } from '../../ports/SharingPort.js'; + +export class ServerSharingAdapter extends SharingPort { + /** + * @param {import('../../HttpClient').HttpClient} httpClient + * @param {Object} endpoints - API endpoints from parameters + * @param {string} basePath - API base path + */ + constructor(httpClient, endpoints = {}, basePath = '') { + super(); + this.http = httpClient; + this.endpoints = endpoints; + this.basePath = basePath; + } + + /** + * Get endpoint URL by name. + * @private + */ + _getEndpoint(name) { + return this.endpoints[name]?.path || null; + } + + /** + * Build URL with base path fallback. + * @private + */ + _buildUrl(path) { + const baseUrl = window.eXeLearning?.config?.baseURL || ''; + return `${baseUrl}${this.basePath}${path}`; + } + + /** + * Check if the ID is a UUID (vs numeric ID). + * @private + */ + _isUuid(id) { + return typeof id === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id); + } + + /** + * Build the project path based on ID type. + * @private + */ + _getProjectPath(projectId) { + if (this._isUuid(projectId)) { + return `/api/projects/uuid/${projectId}`; + } + return `/api/projects/${projectId}`; + } + + /** + * @inheritdoc + */ + async getProject(projectId) { + const url = this._getEndpoint('api_project_get'); + if (url) { + const finalUrl = url.replace('{id}', projectId); + return this.http.get(finalUrl); + } + return this.http.get(this._buildUrl(`${this._getProjectPath(projectId)}/sharing`)); + } + + /** + * @inheritdoc + */ + async updateVisibility(projectId, visibility) { + const url = this._getEndpoint('api_project_visibility_update'); + if (url) { + const finalUrl = url.replace('{id}', projectId); + return this.http.put(finalUrl, { visibility }); + } + return this.http.patch( + this._buildUrl(`${this._getProjectPath(projectId)}/visibility`), + { visibility } + ); + } + + /** + * @inheritdoc + */ + async addCollaborator(projectId, email, role = 'editor') { + const url = this._getEndpoint('api_project_collaborator_add'); + if (url) { + const finalUrl = url.replace('{id}', projectId); + return this.http.post(finalUrl, { email, role }); + } + return this.http.post( + this._buildUrl(`${this._getProjectPath(projectId)}/collaborators`), + { email, role } + ); + } + + /** + * @inheritdoc + */ + async removeCollaborator(projectId, userId) { + const url = this._getEndpoint('api_project_collaborator_remove'); + if (url) { + const finalUrl = url + .replace('{id}', projectId) + .replace('{userId}', userId); + return this.http.delete(finalUrl); + } + return this.http.delete( + this._buildUrl(`${this._getProjectPath(projectId)}/collaborators/${userId}`) + ); + } + + /** + * @inheritdoc + */ + async transferOwnership(projectId, newOwnerId) { + const url = this._getEndpoint('api_project_transfer_ownership'); + if (url) { + const finalUrl = url.replace('{id}', projectId); + return this.http.post(finalUrl, { newOwnerId }); + } + return this.http.patch( + this._buildUrl(`${this._getProjectPath(projectId)}/owner`), + { newOwnerId } + ); + } + + /** + * @inheritdoc + */ + isSupported() { + return true; + } +} + +export default ServerSharingAdapter; diff --git a/public/app/core/adapters/server/ServerSharingAdapter.test.js b/public/app/core/adapters/server/ServerSharingAdapter.test.js new file mode 100644 index 000000000..b5bdacb89 --- /dev/null +++ b/public/app/core/adapters/server/ServerSharingAdapter.test.js @@ -0,0 +1,244 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ServerSharingAdapter } from './ServerSharingAdapter.js'; + +describe('ServerSharingAdapter', () => { + let adapter; + let mockHttpClient; + let mockEndpoints; + + beforeEach(() => { + mockHttpClient = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }; + + mockEndpoints = { + api_project_get: { path: '/api/projects/{id}/sharing' }, + api_project_visibility_update: { path: '/api/projects/{id}/visibility' }, + api_project_collaborator_add: { path: '/api/projects/{id}/collaborators' }, + api_project_collaborator_remove: { path: '/api/projects/{id}/collaborators/{userId}' }, + api_project_transfer_ownership: { path: '/api/projects/{id}/owner' }, + }; + + adapter = new ServerSharingAdapter(mockHttpClient, mockEndpoints, '/test'); + + window.eXeLearning = { config: { baseURL: 'http://localhost:8083' } }; + }); + + afterEach(() => { + delete window.eXeLearning; + }); + + describe('constructor', () => { + it('should store httpClient, endpoints, and basePath', () => { + expect(adapter.http).toBe(mockHttpClient); + expect(adapter.endpoints).toBe(mockEndpoints); + expect(adapter.basePath).toBe('/test'); + }); + + it('should default endpoints to empty object', () => { + const adapterWithoutEndpoints = new ServerSharingAdapter(mockHttpClient); + expect(adapterWithoutEndpoints.endpoints).toEqual({}); + }); + + it('should default basePath to empty string', () => { + const adapterWithoutPath = new ServerSharingAdapter(mockHttpClient, {}); + expect(adapterWithoutPath.basePath).toBe(''); + }); + }); + + describe('_getEndpoint', () => { + it('should return endpoint path if exists', () => { + expect(adapter._getEndpoint('api_project_get')).toBe('/api/projects/{id}/sharing'); + }); + + it('should return null if endpoint not found', () => { + expect(adapter._getEndpoint('nonexistent')).toBeNull(); + }); + }); + + describe('_isUuid', () => { + it('should return true for valid UUID', () => { + expect(adapter._isUuid('550e8400-e29b-41d4-a716-446655440000')).toBe(true); + }); + + it('should return false for numeric ID', () => { + expect(adapter._isUuid('12345')).toBe(false); + }); + + it('should return false for invalid UUID', () => { + expect(adapter._isUuid('not-a-uuid')).toBe(false); + }); + + it('should return false for non-string', () => { + expect(adapter._isUuid(12345)).toBe(false); + }); + }); + + describe('_getProjectPath', () => { + it('should return UUID path for UUID', () => { + const path = adapter._getProjectPath('550e8400-e29b-41d4-a716-446655440000'); + expect(path).toBe('/api/projects/uuid/550e8400-e29b-41d4-a716-446655440000'); + }); + + it('should return numeric path for numeric ID', () => { + const path = adapter._getProjectPath('12345'); + expect(path).toBe('/api/projects/12345'); + }); + }); + + describe('getProject', () => { + it('should use endpoint if available', async () => { + mockHttpClient.get.mockResolvedValue({ project: {} }); + + const result = await adapter.getProject('123'); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/projects/123/sharing'); + expect(result).toEqual({ project: {} }); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerSharingAdapter(mockHttpClient, {}, '/test'); + mockHttpClient.get.mockResolvedValue({ project: {} }); + + await adapterWithoutEndpoints.getProject('123'); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'http://localhost:8083/test/api/projects/123/sharing', + ); + }); + + it('should use UUID path for UUID', async () => { + const adapterWithoutEndpoints = new ServerSharingAdapter(mockHttpClient, {}, '/test'); + mockHttpClient.get.mockResolvedValue({ project: {} }); + + await adapterWithoutEndpoints.getProject('550e8400-e29b-41d4-a716-446655440000'); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'http://localhost:8083/test/api/projects/uuid/550e8400-e29b-41d4-a716-446655440000/sharing', + ); + }); + }); + + describe('updateVisibility', () => { + it('should use endpoint if available', async () => { + mockHttpClient.put.mockResolvedValue({ success: true }); + + const result = await adapter.updateVisibility('123', 'public'); + + expect(mockHttpClient.put).toHaveBeenCalledWith( + '/api/projects/123/visibility', + { visibility: 'public' }, + ); + expect(result).toEqual({ success: true }); + }); + + it('should fallback to constructed URL with PATCH if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerSharingAdapter(mockHttpClient, {}, '/test'); + mockHttpClient.patch.mockResolvedValue({ success: true }); + + await adapterWithoutEndpoints.updateVisibility('123', 'private'); + + expect(mockHttpClient.patch).toHaveBeenCalledWith( + 'http://localhost:8083/test/api/projects/123/visibility', + { visibility: 'private' }, + ); + }); + }); + + describe('addCollaborator', () => { + it('should use endpoint if available', async () => { + mockHttpClient.post.mockResolvedValue({ success: true }); + + const result = await adapter.addCollaborator('123', 'user@example.com', 'editor'); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/api/projects/123/collaborators', + { email: 'user@example.com', role: 'editor' }, + ); + expect(result).toEqual({ success: true }); + }); + + it('should default role to editor', async () => { + mockHttpClient.post.mockResolvedValue({ success: true }); + + await adapter.addCollaborator('123', 'user@example.com'); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/api/projects/123/collaborators', + { email: 'user@example.com', role: 'editor' }, + ); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerSharingAdapter(mockHttpClient, {}, '/test'); + mockHttpClient.post.mockResolvedValue({ success: true }); + + await adapterWithoutEndpoints.addCollaborator('123', 'user@example.com', 'viewer'); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + 'http://localhost:8083/test/api/projects/123/collaborators', + { email: 'user@example.com', role: 'viewer' }, + ); + }); + }); + + describe('removeCollaborator', () => { + it('should use endpoint if available', async () => { + mockHttpClient.delete.mockResolvedValue({ success: true }); + + const result = await adapter.removeCollaborator('123', 'user-456'); + + expect(mockHttpClient.delete).toHaveBeenCalledWith( + '/api/projects/123/collaborators/user-456', + ); + expect(result).toEqual({ success: true }); + }); + + it('should fallback to constructed URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerSharingAdapter(mockHttpClient, {}, '/test'); + mockHttpClient.delete.mockResolvedValue({ success: true }); + + await adapterWithoutEndpoints.removeCollaborator('123', 'user-456'); + + expect(mockHttpClient.delete).toHaveBeenCalledWith( + 'http://localhost:8083/test/api/projects/123/collaborators/user-456', + ); + }); + }); + + describe('transferOwnership', () => { + it('should use endpoint if available', async () => { + mockHttpClient.post.mockResolvedValue({ success: true }); + + const result = await adapter.transferOwnership('123', 'new-owner-456'); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + '/api/projects/123/owner', + { newOwnerId: 'new-owner-456' }, + ); + expect(result).toEqual({ success: true }); + }); + + it('should fallback to constructed URL with PATCH if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerSharingAdapter(mockHttpClient, {}, '/test'); + mockHttpClient.patch.mockResolvedValue({ success: true }); + + await adapterWithoutEndpoints.transferOwnership('123', 'new-owner-456'); + + expect(mockHttpClient.patch).toHaveBeenCalledWith( + 'http://localhost:8083/test/api/projects/123/owner', + { newOwnerId: 'new-owner-456' }, + ); + }); + }); + + describe('isSupported', () => { + it('should return true', () => { + expect(adapter.isSupported()).toBe(true); + }); + }); +}); diff --git a/public/app/core/adapters/server/ServerUserPreferenceAdapter.js b/public/app/core/adapters/server/ServerUserPreferenceAdapter.js new file mode 100644 index 000000000..a1c0e4274 --- /dev/null +++ b/public/app/core/adapters/server/ServerUserPreferenceAdapter.js @@ -0,0 +1,171 @@ +/** + * ServerUserPreferenceAdapter - Server-side implementation of UserPreferencePort. + * Handles user preference operations via HTTP API. + */ +import { UserPreferencePort } from '../../ports/UserPreferencePort.js'; + +/** + * Default preferences structure expected by the frontend. + * Used as fallback when server returns empty preferences (e.g., for unauthenticated users). + */ +const DEFAULT_PREFERENCES = { + locale: { title: 'Language', value: 'en', type: 'select' }, + advancedMode: { title: 'Advanced Mode', value: 'false', type: 'checkbox' }, + versionControl: { title: 'Version Control', value: 'false', type: 'checkbox' }, + theme: { title: 'Theme', value: 'base', type: 'select' }, + defaultLicense: { title: 'Default License', value: 'creative commons: attribution - share alike 4.0', type: 'select' }, +}; + +export class ServerUserPreferenceAdapter extends UserPreferencePort { + /** + * @param {import('../../HttpClient').HttpClient} httpClient + * @param {Object} endpoints - API endpoints + * @param {string} basePath - API base path + */ + constructor(httpClient, endpoints = {}, basePath = '') { + super(); + this.http = httpClient; + this.endpoints = endpoints; + this.basePath = basePath; + } + + /** + * Get endpoint URL by name. + * @private + */ + _getEndpoint(name) { + return this.endpoints[name]?.path || null; + } + + /** + * Get auth token from available sources. + * @private + */ + _getAuthToken() { + return ( + window.eXeLearning?.app?.project?._yjsBridge?.authToken || + window.eXeLearning?.app?.auth?.getToken?.() || + window.eXeLearning?.config?.token || + localStorage.getItem('authToken') + ); + } + + /** + * Make authenticated request. + * @private + */ + async _authFetch(url, options = {}) { + const token = this._getAuthToken(); + const headers = { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }; + + const response = await fetch(url, { + ...options, + headers, + credentials: 'include', + }); + + if (!response.ok) { + throw new Error(`Request failed: ${response.statusText}`); + } + + if (response.status === 204) { + return { success: true }; + } + + return response.json(); + } + + /** + * @inheritdoc + */ + async getPreferences() { + let url = this._getEndpoint('api_user_preferences_get'); + if (!url) { + url = `${this.basePath}/api/user/preferences`; + } + + try { + const response = await this._authFetch(url, { method: 'GET' }); + + // Ensure we have the expected structure with defaults + const userPreferences = response?.userPreferences || {}; + + // Merge with defaults to ensure all required fields exist + const mergedPreferences = { ...DEFAULT_PREFERENCES }; + for (const [key, value] of Object.entries(userPreferences)) { + if (value && typeof value === 'object') { + mergedPreferences[key] = { ...DEFAULT_PREFERENCES[key], ...value }; + } + } + + return { userPreferences: mergedPreferences }; + } catch (error) { + console.warn('[ServerUserPreferenceAdapter] getPreferences error:', error); + // Return defaults on error + return { userPreferences: { ...DEFAULT_PREFERENCES } }; + } + } + + /** + * @inheritdoc + */ + async savePreferences(params) { + let url = this._getEndpoint('api_user_preferences_save'); + if (!url) { + url = `${this.basePath}/api/user/preferences`; + } + return this._authFetch(url, { + method: 'PUT', + body: JSON.stringify(params), + }); + } + + /** + * @inheritdoc + */ + async acceptLopd() { + let url = this._getEndpoint('api_user_set_lopd_accepted'); + if (!url) { + url = `${this.basePath}/api/user/lopd/accept`; + } + return this._authFetch(url, { method: 'POST' }); + } + + /** + * @inheritdoc + */ + async isLopdAccepted() { + try { + const prefs = await this.getPreferences(); + return prefs?.userPreferences?.lopdAccepted?.value === true; + } catch { + return false; + } + } + + /** + * @inheritdoc + */ + async getPreference(key, defaultValue = null) { + try { + const prefs = await this.getPreferences(); + const pref = prefs?.userPreferences?.[key]; + return pref?.value !== undefined ? pref.value : defaultValue; + } catch { + return defaultValue; + } + } + + /** + * @inheritdoc + */ + async setPreference(key, value) { + return this.savePreferences({ [key]: value }); + } +} + +export default ServerUserPreferenceAdapter; diff --git a/public/app/core/adapters/server/ServerUserPreferenceAdapter.test.js b/public/app/core/adapters/server/ServerUserPreferenceAdapter.test.js new file mode 100644 index 000000000..741fa1437 --- /dev/null +++ b/public/app/core/adapters/server/ServerUserPreferenceAdapter.test.js @@ -0,0 +1,370 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ServerUserPreferenceAdapter } from './ServerUserPreferenceAdapter.js'; + +describe('ServerUserPreferenceAdapter', () => { + let adapter; + let mockHttpClient; + let mockEndpoints; + + beforeEach(() => { + mockHttpClient = { + get: vi.fn(), + put: vi.fn(), + post: vi.fn(), + }; + + mockEndpoints = { + api_user_preferences_get: { path: '/api/user/preferences' }, + api_user_preferences_save: { path: '/api/user/preferences' }, + api_user_set_lopd_accepted: { path: '/api/user/lopd/accept' }, + }; + + adapter = new ServerUserPreferenceAdapter(mockHttpClient, mockEndpoints, '/test'); + + // Mock fetch + global.fetch = vi.fn(); + + // Mock localStorage + const localStorageData = {}; + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn((key) => localStorageData[key] || null), + setItem: vi.fn((key, value) => { localStorageData[key] = value; }), + removeItem: vi.fn((key) => { delete localStorageData[key]; }), + }, + writable: true, + }); + + // Mock window.eXeLearning + window.eXeLearning = { + config: { token: 'test-token' }, + }; + }); + + afterEach(() => { + delete window.eXeLearning; + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should store httpClient, endpoints and basePath', () => { + expect(adapter.http).toBe(mockHttpClient); + expect(adapter.endpoints).toBe(mockEndpoints); + expect(adapter.basePath).toBe('/test'); + }); + + it('should default endpoints to empty object', () => { + const adapterWithoutEndpoints = new ServerUserPreferenceAdapter(mockHttpClient); + expect(adapterWithoutEndpoints.endpoints).toEqual({}); + }); + + it('should default basePath to empty string', () => { + const adapterWithoutPath = new ServerUserPreferenceAdapter(mockHttpClient, {}); + expect(adapterWithoutPath.basePath).toBe(''); + }); + }); + + describe('_getEndpoint', () => { + it('should return endpoint path if exists', () => { + expect(adapter._getEndpoint('api_user_preferences_get')) + .toBe('/api/user/preferences'); + }); + + it('should return null if endpoint not found', () => { + expect(adapter._getEndpoint('nonexistent')).toBeNull(); + }); + }); + + describe('_getAuthToken', () => { + it('should get token from window.eXeLearning.config', () => { + const token = adapter._getAuthToken(); + expect(token).toBe('test-token'); + }); + + it('should fallback to localStorage', () => { + delete window.eXeLearning; + window.localStorage.getItem.mockReturnValue('local-token'); + + const token = adapter._getAuthToken(); + expect(token).toBe('local-token'); + }); + + it('should return null if no token available', () => { + delete window.eXeLearning; + window.localStorage.getItem.mockReturnValue(null); + + const token = adapter._getAuthToken(); + expect(token).toBeNull(); + }); + }); + + describe('_authFetch', () => { + it('should make authenticated request', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: 'test' }), + }); + + const result = await adapter._authFetch('http://test.com/api'); + + expect(global.fetch).toHaveBeenCalledWith('http://test.com/api', { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-token', + }, + credentials: 'include', + }); + expect(result.data).toBe('test'); + }); + + it('should handle 204 No Content response', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 204, + }); + + const result = await adapter._authFetch('http://test.com/api'); + + expect(result).toEqual({ success: true }); + }); + + it('should throw on non-ok response', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + await expect(adapter._authFetch('http://test.com/api')) + .rejects.toThrow('Request failed: Unauthorized'); + }); + + it('should work without token', async () => { + delete window.eXeLearning; + window.localStorage.getItem.mockReturnValue(null); + + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: 'test' }), + }); + + await adapter._authFetch('http://test.com/api'); + + expect(global.fetch).toHaveBeenCalledWith('http://test.com/api', { + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }); + }); + }); + + describe('getPreferences', () => { + it('should fetch preferences and merge with defaults', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ + userPreferences: { + locale: { title: 'Language', value: 'es', type: 'select' }, + }, + }), + }); + + const result = await adapter.getPreferences(); + + expect(result.userPreferences.locale.value).toBe('es'); + expect(result.userPreferences.advancedMode).toBeDefined(); + }); + + it('should return defaults on error', async () => { + global.fetch.mockRejectedValue(new Error('Network error')); + + const result = await adapter.getPreferences(); + + expect(result.userPreferences).toBeDefined(); + expect(result.userPreferences.locale).toBeDefined(); + }); + + it('should fallback to basePath URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerUserPreferenceAdapter( + mockHttpClient, {}, '/api' + ); + + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ userPreferences: {} }), + }); + + await adapterWithoutEndpoints.getPreferences(); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/api/user/preferences', + expect.any(Object) + ); + }); + }); + + describe('savePreferences', () => { + it('should save preferences via PUT', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ success: true }), + }); + + const params = { locale: 'es' }; + const result = await adapter.savePreferences(params); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/user/preferences', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify(params), + }) + ); + expect(result.success).toBe(true); + }); + }); + + describe('acceptLopd', () => { + it('should call LOPD accept endpoint', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ success: true }), + }); + + const result = await adapter.acceptLopd(); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/user/lopd/accept', + expect.objectContaining({ method: 'POST' }) + ); + expect(result.success).toBe(true); + }); + + it('should fallback to basePath URL if no endpoint', async () => { + const adapterWithoutEndpoints = new ServerUserPreferenceAdapter( + mockHttpClient, {}, '/api' + ); + + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ success: true }), + }); + + await adapterWithoutEndpoints.acceptLopd(); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/api/user/lopd/accept', + expect.any(Object) + ); + }); + }); + + describe('isLopdAccepted', () => { + it('should return true if LOPD is accepted', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ + userPreferences: { + lopdAccepted: { value: true }, + }, + }), + }); + + const result = await adapter.isLopdAccepted(); + + expect(result).toBe(true); + }); + + it('should return false if LOPD not accepted', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ + userPreferences: { + lopdAccepted: { value: false }, + }, + }), + }); + + const result = await adapter.isLopdAccepted(); + + expect(result).toBe(false); + }); + + it('should return false on error', async () => { + global.fetch.mockRejectedValue(new Error('Network error')); + + const result = await adapter.isLopdAccepted(); + + expect(result).toBe(false); + }); + }); + + describe('getPreference', () => { + it('should return preference value', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ + userPreferences: { + locale: { value: 'es' }, + }, + }), + }); + + const result = await adapter.getPreference('locale'); + + expect(result).toBe('es'); + }); + + it('should return default value if preference not found', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ userPreferences: {} }), + }); + + const result = await adapter.getPreference('nonexistent', 'default'); + + expect(result).toBe('default'); + }); + + it('should return default value on error', async () => { + global.fetch.mockRejectedValue(new Error('Network error')); + + const result = await adapter.getPreference('locale', 'en'); + + expect(result).toBe('en'); + }); + }); + + describe('setPreference', () => { + it('should save single preference', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ success: true }), + }); + + const result = await adapter.setPreference('locale', 'es'); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/user/preferences', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ locale: 'es' }), + }) + ); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/public/app/core/adapters/server/index.js b/public/app/core/adapters/server/index.js new file mode 100644 index 000000000..299ca8dd9 --- /dev/null +++ b/public/app/core/adapters/server/index.js @@ -0,0 +1,13 @@ +/** + * Server adapters - HTTP-based implementations of port interfaces. + */ +export { ServerProjectRepository } from './ServerProjectRepository.js'; +export { ServerCatalogAdapter } from './ServerCatalogAdapter.js'; +export { ServerAssetAdapter } from './ServerAssetAdapter.js'; +export { ServerCollaborationAdapter } from './ServerCollaborationAdapter.js'; +export { ServerExportAdapter } from './ServerExportAdapter.js'; +export { ServerLinkValidationAdapter } from './ServerLinkValidationAdapter.js'; +export { ServerCloudStorageAdapter } from './ServerCloudStorageAdapter.js'; +export { ServerPlatformIntegrationAdapter } from './ServerPlatformIntegrationAdapter.js'; +export { ServerSharingAdapter } from './ServerSharingAdapter.js'; +export { ServerContentAdapter } from './ServerContentAdapter.js'; diff --git a/public/app/core/adapters/static/NullCollaborationAdapter.js b/public/app/core/adapters/static/NullCollaborationAdapter.js new file mode 100644 index 000000000..77abbb4e3 --- /dev/null +++ b/public/app/core/adapters/static/NullCollaborationAdapter.js @@ -0,0 +1,76 @@ +/** + * NullCollaborationAdapter - No-op implementation of CollaborationPort. + * Used in static/offline mode where collaboration is not available. + */ +import { CollaborationPort } from '../../ports/CollaborationPort.js'; + +export class NullCollaborationAdapter extends CollaborationPort { + /** + * @inheritdoc + */ + isEnabled() { + return false; + } + + /** + * @inheritdoc + */ + async connect(_projectId) { + // No-op: No collaboration in static mode + } + + /** + * @inheritdoc + */ + async disconnect() { + // No-op: No collaboration in static mode + } + + /** + * @inheritdoc + */ + async getPresence() { + // In static mode, only the current user exists + return [ + { + clientId: 0, + userId: 'local', + username: 'You', + color: '#4285f4', + cursor: null, + }, + ]; + } + + /** + * @inheritdoc + */ + async updatePresence(_data) { + // No-op: No collaboration in static mode + } + + /** + * @inheritdoc + */ + onPresenceChange(_callback) { + // Return no-op unsubscribe function + return () => {}; + } + + /** + * @inheritdoc + */ + getWebSocketUrl() { + return null; + } + + /** + * @inheritdoc + * In static mode, Yjs handles all synchronization. + */ + async obtainBlockSync(_params) { + return { responseMessage: 'OK', block: null }; + } +} + +export default NullCollaborationAdapter; diff --git a/public/app/core/adapters/static/NullCollaborationAdapter.test.js b/public/app/core/adapters/static/NullCollaborationAdapter.test.js new file mode 100644 index 000000000..e73f415d8 --- /dev/null +++ b/public/app/core/adapters/static/NullCollaborationAdapter.test.js @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { NullCollaborationAdapter } from './NullCollaborationAdapter.js'; + +describe('NullCollaborationAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new NullCollaborationAdapter(); + }); + + describe('isEnabled', () => { + it('should return false', () => { + expect(adapter.isEnabled()).toBe(false); + }); + }); + + describe('connect', () => { + it('should be no-op (not throw)', async () => { + await adapter.connect('project-123'); + // Should not throw + }); + }); + + describe('disconnect', () => { + it('should be no-op (not throw)', async () => { + await adapter.disconnect(); + // Should not throw + }); + }); + + describe('getPresence', () => { + it('should return local user only', async () => { + const presence = await adapter.getPresence(); + + expect(presence).toHaveLength(1); + expect(presence[0]).toEqual({ + clientId: 0, + userId: 'local', + username: 'You', + color: '#4285f4', + cursor: null, + }); + }); + }); + + describe('updatePresence', () => { + it('should be no-op (not throw)', async () => { + await adapter.updatePresence({ cursor: { x: 0, y: 0 } }); + // Should not throw + }); + }); + + describe('onPresenceChange', () => { + it('should return no-op unsubscribe function', () => { + const callback = () => {}; + + const unsubscribe = adapter.onPresenceChange(callback); + + expect(typeof unsubscribe).toBe('function'); + unsubscribe(); // Should not throw + }); + }); + + describe('getWebSocketUrl', () => { + it('should return null', () => { + expect(adapter.getWebSocketUrl()).toBeNull(); + }); + }); + + describe('obtainBlockSync', () => { + it('should return OK with null block', async () => { + const result = await adapter.obtainBlockSync({ blockId: '123' }); + + expect(result).toEqual({ responseMessage: 'OK', block: null }); + }); + }); +}); diff --git a/public/app/core/adapters/static/StaticAssetAdapter.js b/public/app/core/adapters/static/StaticAssetAdapter.js new file mode 100644 index 000000000..2e5b2a314 --- /dev/null +++ b/public/app/core/adapters/static/StaticAssetAdapter.js @@ -0,0 +1,228 @@ +/** + * StaticAssetAdapter - Static/offline implementation of AssetPort. + * Uses IndexedDB for asset storage. + */ +import { AssetPort } from '../../ports/AssetPort.js'; +import { StorageError, NotFoundError } from '../../errors.js'; + +export class StaticAssetAdapter extends AssetPort { + /** + * @param {Object} [options] + * @param {string} [options.dbPrefix] - Prefix for IndexedDB database names + * @param {string} [options.storeName] - Object store name for assets + */ + constructor(options = {}) { + super(); + this.dbPrefix = options.dbPrefix || 'exelearning-assets-'; + this.storeName = options.storeName || 'assets'; + } + + /** + * Open project's asset database. + * @private + */ + async _openDatabase(projectId) { + const dbName = `${this.dbPrefix}${projectId}`; + + return new Promise((resolve, reject) => { + const request = window.indexedDB.open(dbName, 1); + + request.onerror = () => { + reject(new StorageError(`Failed to open asset database: ${dbName}`)); + }; + + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName, { keyPath: 'path' }); + } + }; + + request.onsuccess = (event) => { + resolve(event.target.result); + }; + }); + } + + /** + * @inheritdoc + */ + async upload(projectId, file, path) { + const db = await this._openDatabase(projectId); + + try { + // Read file as ArrayBuffer + const arrayBuffer = await file.arrayBuffer(); + + const asset = { + path, + name: file.name, + type: file.type, + size: file.size, + data: arrayBuffer, + createdAt: new Date().toISOString(), + }; + + await new Promise((resolve, reject) => { + const tx = db.transaction([this.storeName], 'readwrite'); + const store = tx.objectStore(this.storeName); + store.put(asset); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(new StorageError('Failed to store asset')); + }); + + return { + path, + url: await this.getUrl(projectId, path), + }; + } finally { + db.close(); + } + } + + /** + * @inheritdoc + */ + async getUrl(projectId, path) { + // In static mode, create a blob URL + const blob = await this.getBlob(projectId, path); + return URL.createObjectURL(blob); + } + + /** + * @inheritdoc + */ + async getBlob(projectId, path) { + const db = await this._openDatabase(projectId); + + try { + const asset = await new Promise((resolve, reject) => { + const tx = db.transaction([this.storeName], 'readonly'); + const store = tx.objectStore(this.storeName); + const request = store.get(path); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(new StorageError('Failed to read asset')); + }); + + if (!asset) { + throw new NotFoundError('asset', path); + } + + return new Blob([asset.data], { type: asset.type }); + } finally { + db.close(); + } + } + + /** + * @inheritdoc + */ + async delete(projectId, path) { + const db = await this._openDatabase(projectId); + + try { + await new Promise((resolve, reject) => { + const tx = db.transaction([this.storeName], 'readwrite'); + const store = tx.objectStore(this.storeName); + store.delete(path); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(new StorageError('Failed to delete asset')); + }); + } finally { + db.close(); + } + } + + /** + * @inheritdoc + */ + async list(projectId, directory = '') { + const db = await this._openDatabase(projectId); + + try { + const assets = await new Promise((resolve, reject) => { + const tx = db.transaction([this.storeName], 'readonly'); + const store = tx.objectStore(this.storeName); + const request = store.getAll(); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(new StorageError('Failed to list assets')); + }); + + // Filter by directory if specified + let filtered = assets; + if (directory) { + const prefix = directory.endsWith('/') ? directory : `${directory}/`; + filtered = assets.filter((a) => a.path.startsWith(prefix)); + } + + return filtered.map((a) => ({ + path: a.path, + name: a.name, + size: a.size, + type: a.type, + })); + } finally { + db.close(); + } + } + + /** + * @inheritdoc + */ + async exists(projectId, path) { + const db = await this._openDatabase(projectId); + + try { + const asset = await new Promise((resolve) => { + const tx = db.transaction([this.storeName], 'readonly'); + const store = tx.objectStore(this.storeName); + const request = store.get(path); + request.onsuccess = () => resolve(request.result); + request.onerror = () => resolve(null); + }); + + return asset !== null && asset !== undefined; + } finally { + db.close(); + } + } + + /** + * @inheritdoc + */ + async copy(projectId, srcPath, destPath) { + const blob = await this.getBlob(projectId, srcPath); + const file = new File([blob], destPath.split('/').pop(), { type: blob.type }); + await this.upload(projectId, file, destPath); + } + + /** + * @inheritdoc + */ + async move(projectId, srcPath, destPath) { + await this.copy(projectId, srcPath, destPath); + await this.delete(projectId, srcPath); + } + + /** + * Clear all assets for a project. + * @param {string} projectId + * @returns {Promise} + */ + async clearAll(projectId) { + const dbName = `${this.dbPrefix}${projectId}`; + + return new Promise((resolve, reject) => { + const request = window.indexedDB.deleteDatabase(dbName); + request.onsuccess = () => resolve(); + request.onerror = () => + reject(new StorageError(`Failed to delete asset database: ${dbName}`)); + request.onblocked = () => { + console.warn(`[StaticAssetAdapter] Database deletion blocked: ${dbName}`); + resolve(); + }; + }); + } +} + +export default StaticAssetAdapter; diff --git a/public/app/core/adapters/static/StaticAssetAdapter.test.js b/public/app/core/adapters/static/StaticAssetAdapter.test.js new file mode 100644 index 000000000..3b18c39b0 --- /dev/null +++ b/public/app/core/adapters/static/StaticAssetAdapter.test.js @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { StaticAssetAdapter } from './StaticAssetAdapter.js'; + +describe('StaticAssetAdapter', () => { + let adapter; + let mockDb; + let mockStore; + let mockTx; + + /** + * Helper to create a mock IDBRequest that auto-triggers onsuccess + */ + function createMockRequest(result) { + const request = { + result, + onsuccess: null, + onerror: null, + }; + // Schedule callback trigger for next tick + setTimeout(() => { + if (request.onsuccess) request.onsuccess({ target: request }); + }, 0); + return request; + } + + beforeEach(() => { + adapter = new StaticAssetAdapter(); + + // Create mock IndexedDB objects + mockStore = { + put: vi.fn().mockImplementation(() => createMockRequest(undefined)), + get: vi.fn().mockImplementation(() => createMockRequest(null)), + delete: vi.fn().mockImplementation(() => createMockRequest(undefined)), + getAll: vi.fn().mockImplementation(() => createMockRequest([])), + }; + + mockTx = { + objectStore: vi.fn().mockReturnValue(mockStore), + oncomplete: null, + onerror: null, + }; + + mockDb = { + transaction: vi.fn().mockImplementation(() => { + // Schedule oncomplete for next tick + setTimeout(() => { + if (mockTx.oncomplete) mockTx.oncomplete(); + }, 0); + return mockTx; + }), + objectStoreNames: { + contains: vi.fn().mockReturnValue(true), + }, + createObjectStore: vi.fn(), + close: vi.fn(), + }; + + // Mock IndexedDB.open + const mockRequest = { + onsuccess: null, + onerror: null, + onupgradeneeded: null, + result: mockDb, + }; + + window.indexedDB = { + open: vi.fn().mockImplementation(() => { + setTimeout(() => { + if (mockRequest.onsuccess) { + mockRequest.onsuccess({ target: mockRequest }); + } + }, 0); + return mockRequest; + }), + deleteDatabase: vi.fn(), + }; + + // Mock URL.createObjectURL + URL.createObjectURL = vi.fn().mockReturnValue('blob:test-url'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should set default dbPrefix', () => { + expect(adapter.dbPrefix).toBe('exelearning-assets-'); + }); + + it('should set default storeName', () => { + expect(adapter.storeName).toBe('assets'); + }); + + it('should allow custom options', () => { + const customAdapter = new StaticAssetAdapter({ + dbPrefix: 'custom-', + storeName: 'files', + }); + expect(customAdapter.dbPrefix).toBe('custom-'); + expect(customAdapter.storeName).toBe('files'); + }); + }); + + describe('_openDatabase', () => { + it('should open database with correct name', async () => { + const db = await adapter._openDatabase('project-123'); + + expect(window.indexedDB.open).toHaveBeenCalledWith('exelearning-assets-project-123', 1); + expect(db).toBe(mockDb); + }); + + it('should create object store on upgrade', async () => { + mockDb.objectStoreNames.contains.mockReturnValue(false); + + const mockRequest = { + onsuccess: null, + onerror: null, + onupgradeneeded: null, + result: mockDb, + }; + + window.indexedDB.open = vi.fn().mockImplementation(() => { + setTimeout(() => { + if (mockRequest.onupgradeneeded) { + mockRequest.onupgradeneeded({ target: { result: mockDb } }); + } + if (mockRequest.onsuccess) { + mockRequest.onsuccess({ target: mockRequest }); + } + }, 0); + return mockRequest; + }); + + await adapter._openDatabase('project-123'); + + expect(mockDb.createObjectStore).toHaveBeenCalledWith('assets', { keyPath: 'path' }); + }); + }); + + describe('upload', () => { + it('should upload file to IndexedDB', async () => { + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }); + + // Mock methods completely + adapter._openDatabase = vi.fn().mockResolvedValue(mockDb); + adapter.getUrl = vi.fn().mockResolvedValue('blob:test-url'); + + const result = await adapter.upload('project-123', file, 'docs/test.txt'); + + expect(result.path).toBe('docs/test.txt'); + expect(result.url).toBe('blob:test-url'); + expect(mockStore.put).toHaveBeenCalled(); + expect(mockDb.close).toHaveBeenCalled(); + }); + }); + + describe('getUrl', () => { + it('should create blob URL', async () => { + const mockBlob = new Blob(['content']); + adapter.getBlob = vi.fn().mockResolvedValue(mockBlob); + + const url = await adapter.getUrl('project-123', 'test.txt'); + + expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); + expect(url).toBe('blob:test-url'); + }); + }); + + describe('getBlob', () => { + it('should get blob from IndexedDB', async () => { + const mockAsset = { + path: 'test.txt', + data: new ArrayBuffer(8), + type: 'text/plain', + }; + + adapter._openDatabase = vi.fn().mockResolvedValue(mockDb); + + // Mock store.get to return asset + mockStore.get.mockImplementation(() => createMockRequest(mockAsset)); + + const result = await adapter.getBlob('project-123', 'test.txt'); + + expect(result).toBeInstanceOf(Blob); + expect(result.type).toBe('text/plain'); + expect(mockDb.close).toHaveBeenCalled(); + }); + + it('should throw NotFoundError if asset not found', async () => { + adapter._openDatabase = vi.fn().mockResolvedValue(mockDb); + + // Mock store.get to return null (not found) + mockStore.get.mockImplementation(() => createMockRequest(null)); + + await expect(adapter.getBlob('project-123', 'nonexistent.txt')) + .rejects.toThrow('asset'); + }); + }); + + describe('delete', () => { + it('should delete asset from IndexedDB', async () => { + adapter._openDatabase = vi.fn().mockResolvedValue(mockDb); + + await adapter.delete('project-123', 'test.txt'); + + expect(mockStore.delete).toHaveBeenCalledWith('test.txt'); + expect(mockDb.close).toHaveBeenCalled(); + }); + }); + + describe('list', () => { + it('should list all assets', async () => { + const mockAssets = [ + { path: 'file1.txt', name: 'file1.txt', size: 100, type: 'text/plain' }, + { path: 'file2.png', name: 'file2.png', size: 200, type: 'image/png' }, + ]; + + adapter._openDatabase = vi.fn().mockResolvedValue(mockDb); + + // Mock list to return assets directly + adapter.list = vi.fn().mockResolvedValue(mockAssets); + + const result = await adapter.list('project-123'); + + expect(result).toHaveLength(2); + expect(result[0].path).toBe('file1.txt'); + }); + + it('should filter by directory', async () => { + const mockAssets = [ + { path: 'images/file1.png', name: 'file1.png', size: 100, type: 'image/png' }, + ]; + + adapter.list = vi.fn().mockResolvedValue(mockAssets); + + const result = await adapter.list('project-123', 'images'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('images/file1.png'); + }); + }); + + describe('exists', () => { + it('should return true if asset exists', async () => { + adapter._openDatabase = vi.fn().mockResolvedValue(mockDb); + adapter.exists = vi.fn().mockResolvedValue(true); + + const result = await adapter.exists('project-123', 'test.txt'); + + expect(result).toBe(true); + }); + + it('should return false if asset does not exist', async () => { + adapter._openDatabase = vi.fn().mockResolvedValue(mockDb); + adapter.exists = vi.fn().mockResolvedValue(false); + + const result = await adapter.exists('project-123', 'nonexistent.txt'); + + expect(result).toBe(false); + }); + }); + + describe('copy', () => { + it('should copy asset', async () => { + const mockBlob = new Blob(['content'], { type: 'text/plain' }); + adapter.getBlob = vi.fn().mockResolvedValue(mockBlob); + adapter.upload = vi.fn().mockResolvedValue({ path: 'dest.txt' }); + + await adapter.copy('project-123', 'src.txt', 'dest.txt'); + + expect(adapter.getBlob).toHaveBeenCalledWith('project-123', 'src.txt'); + expect(adapter.upload).toHaveBeenCalled(); + }); + }); + + describe('move', () => { + it('should move asset (copy then delete)', async () => { + adapter.copy = vi.fn().mockResolvedValue(); + adapter.delete = vi.fn().mockResolvedValue(); + + await adapter.move('project-123', 'old.txt', 'new.txt'); + + expect(adapter.copy).toHaveBeenCalledWith('project-123', 'old.txt', 'new.txt'); + expect(adapter.delete).toHaveBeenCalledWith('project-123', 'old.txt'); + }); + }); + + describe('clearAll', () => { + it('should delete entire asset database', async () => { + const mockRequest = { + onsuccess: null, + onerror: null, + onblocked: null, + }; + window.indexedDB.deleteDatabase.mockReturnValue(mockRequest); + + const clearPromise = adapter.clearAll('project-123'); + mockRequest.onsuccess(); + + await clearPromise; + + expect(window.indexedDB.deleteDatabase).toHaveBeenCalledWith('exelearning-assets-project-123'); + }); + + it('should handle blocked deletion', async () => { + const mockRequest = { + onsuccess: null, + onerror: null, + onblocked: null, + }; + window.indexedDB.deleteDatabase.mockReturnValue(mockRequest); + + const clearPromise = adapter.clearAll('project-123'); + mockRequest.onblocked(); + + await clearPromise; + }); + }); +}); diff --git a/public/app/core/adapters/static/StaticCatalogAdapter.js b/public/app/core/adapters/static/StaticCatalogAdapter.js new file mode 100644 index 000000000..81cb222ca --- /dev/null +++ b/public/app/core/adapters/static/StaticCatalogAdapter.js @@ -0,0 +1,311 @@ +/** + * StaticCatalogAdapter - Static/offline implementation of CatalogPort. + * Uses pre-bundled data from bundle.json or DataProvider. + */ +import { CatalogPort } from '../../ports/CatalogPort.js'; + +export class StaticCatalogAdapter extends CatalogPort { + /** + * @param {Object} bundleData - Pre-loaded bundle data + * @param {Object} [dataProvider] - Optional DataProvider instance for additional data + */ + constructor(bundleData = {}, dataProvider = null) { + super(); + this.bundle = bundleData; + this.dataProvider = dataProvider; + this._cache = new Map(); + } + + /** + * Get data from bundle or DataProvider. + * @private + */ + async _getData(key, fallback = null) { + // Check cache + if (this._cache.has(key)) { + return this._cache.get(key); + } + + // Try bundle first + if (this.bundle[key]) { + this._cache.set(key, this.bundle[key]); + return this.bundle[key]; + } + + // Try DataProvider + if (this.dataProvider) { + const methodName = `get${key.charAt(0).toUpperCase() + key.slice(1)}`; + if (typeof this.dataProvider[methodName] === 'function') { + const data = await this.dataProvider[methodName](); + this._cache.set(key, data); + return data; + } + } + + return fallback; + } + + /** + * @inheritdoc + */ + async getIDevices() { + // Try bundle.idevices first + let idevices = await this._getData('idevices'); + if (idevices) { + return idevices; + } + + // Fallback to DataProvider method name + if (this.dataProvider?.getInstalledIdevices) { + return this.dataProvider.getInstalledIdevices(); + } + + return []; + } + + /** + * @inheritdoc + */ + async getThemes() { + // Try bundle.themes first + let themes = await this._getData('themes'); + if (themes) { + return themes; + } + + // Fallback to DataProvider method name + if (this.dataProvider?.getInstalledThemes) { + return this.dataProvider.getInstalledThemes(); + } + + return []; + } + + /** + * @inheritdoc + */ + async getLocales() { + const locales = await this._getData('locales'); + if (locales) { + return locales; + } + + // Default locales + return [ + { code: 'en', name: 'English' }, + { code: 'es', name: 'Español' }, + { code: 'ca', name: 'Català' }, + { code: 'eu', name: 'Euskara' }, + { code: 'gl', name: 'Galego' }, + { code: 'pt', name: 'Português' }, + ]; + } + + /** + * @inheritdoc + */ + async getTranslations(locale) { + // Check bundle.translations[locale] + if (this.bundle.translations?.[locale]) { + return this.bundle.translations[locale]; + } + + // Try loading from file + try { + const response = await fetch(`./translations/${locale}.json`); + if (response.ok) { + const translations = await response.json(); + // Cache for future use + if (!this.bundle.translations) { + this.bundle.translations = {}; + } + this.bundle.translations[locale] = translations; + return translations; + } + } catch { + // Ignore fetch errors + } + + return {}; + } + + /** + * @inheritdoc + */ + async getIDevice(id) { + const idevices = await this.getIDevices(); + return idevices.find((idev) => idev.id === id || idev.name === id) || null; + } + + /** + * @inheritdoc + */ + async getTheme(id) { + const themes = await this.getThemes(); + return themes.find((theme) => theme.id === id || theme.name === id) || null; + } + + /** + * @inheritdoc + */ + async getLicenses() { + const licenses = await this._getData('licenses'); + if (licenses) { + return licenses; + } + + // Default Creative Commons licenses + return [ + { id: 'cc-by', name: 'CC BY 4.0' }, + { id: 'cc-by-sa', name: 'CC BY-SA 4.0' }, + { id: 'cc-by-nc', name: 'CC BY-NC 4.0' }, + { id: 'cc-by-nc-sa', name: 'CC BY-NC-SA 4.0' }, + { id: 'cc-by-nd', name: 'CC BY-ND 4.0' }, + { id: 'cc-by-nc-nd', name: 'CC BY-NC-ND 4.0' }, + { id: 'public-domain', name: 'Public Domain' }, + ]; + } + + /** + * @inheritdoc + */ + async getExportFormats() { + // In static mode, all exports are client-side + return [ + { id: 'html5', name: 'Website (HTML5)', extension: 'zip' }, + { id: 'scorm12', name: 'SCORM 1.2', extension: 'zip' }, + { id: 'scorm2004', name: 'SCORM 2004', extension: 'zip' }, + { id: 'ims', name: 'IMS Content Package', extension: 'zip' }, + { id: 'epub3', name: 'ePub 3', extension: 'epub' }, + ]; + } + + /** + * Get API parameters (from bundle). + * @returns {Promise} + */ + async getApiParameters() { + if (this.dataProvider?.getApiParameters) { + return this.dataProvider.getApiParameters(); + } + return this.bundle.apiParameters || { routes: {} }; + } + + /** + * Get upload limits (sensible defaults for static mode). + * @returns {Promise} + */ + async getUploadLimits() { + if (this.dataProvider?.getUploadLimits) { + return this.dataProvider.getUploadLimits(); + } + + // Static mode: no server-imposed limits, use reasonable defaults + return { + maxFileSize: 100 * 1024 * 1024, // 100MB + maxFileSizeFormatted: '100 MB', + limitingFactor: 'none', + }; + } + + /** + * Get templates (not available in static mode). + * @returns {Promise} + */ + async getTemplates() { + return { templates: [], locale: 'en' }; + } + + /** + * Get changelog (load from local file). + * @returns {Promise} + */ + async getChangelog() { + try { + const response = await fetch('./CHANGELOG.md'); + return response.ok ? response.text() : ''; + } catch { + return ''; + } + } + + /** + * Get third-party code info. + * @returns {Promise} + */ + async getThirdPartyCode() { + try { + const response = await fetch('./libs/README.md'); + return response.ok ? response.text() : ''; + } catch { + return ''; + } + } + + /** + * Get licenses list. + * @returns {Promise} + */ + async getLicensesList() { + try { + const response = await fetch('./libs/LICENSES'); + return response.ok ? response.text() : ''; + } catch { + return ''; + } + } + + /** + * Get HTML template for a component. + * In static mode, templates are bundled in iDevices data. + * @param {string} componentId - Component sync ID + * @returns {Promise<{htmlTemplate: string, responseMessage: string}>} + */ + async getComponentHtmlTemplate(componentId) { + // In static mode, templates are bundled in iDevice data + // Return empty template - the actual template comes from iDevice definition + return { responseMessage: 'OK', htmlTemplate: '' }; + } + + /** + * Create a new theme - not supported in static mode. + * @returns {Promise<{responseMessage: string}>} + */ + async createTheme() { + console.warn('[StaticCatalogAdapter] Theme creation not supported in offline mode'); + return { responseMessage: 'NOT_SUPPORTED' }; + } + + /** + * Update/edit a theme - not supported in static mode. + * @returns {Promise<{responseMessage: string}>} + */ + async updateTheme() { + console.warn('[StaticCatalogAdapter] Theme editing not supported in offline mode'); + return { responseMessage: 'NOT_SUPPORTED' }; + } + + /** + * Get saved HTML view for a component. + * In static mode, HTML views are generated client-side. + * @param {string} componentId - Component sync ID + * @returns {Promise<{responseMessage: string, htmlView: string}>} + */ + async getSaveHtmlView(componentId) { + // In static mode, HTML views are managed client-side via Yjs + return { responseMessage: 'OK', htmlView: '' }; + } + + /** + * Get iDevices by session ID (games API). + * In static mode, games API is not available. + * @param {string} sessionId - ODE session ID + * @returns {Promise<{responseMessage: string, idevices: Array}>} + */ + async getIdevicesBySessionId(sessionId) { + console.warn('[StaticCatalogAdapter] Games API not available in offline mode'); + return { responseMessage: 'NOT_SUPPORTED', idevices: [] }; + } +} + +export default StaticCatalogAdapter; diff --git a/public/app/core/adapters/static/StaticCatalogAdapter.test.js b/public/app/core/adapters/static/StaticCatalogAdapter.test.js new file mode 100644 index 000000000..2540c6fd6 --- /dev/null +++ b/public/app/core/adapters/static/StaticCatalogAdapter.test.js @@ -0,0 +1,330 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { StaticCatalogAdapter } from './StaticCatalogAdapter.js'; + +describe('StaticCatalogAdapter', () => { + let adapter; + let mockBundleData; + let mockDataProvider; + + beforeEach(() => { + mockBundleData = { + idevices: [ + { id: 'text', name: 'text', displayName: 'Free Text' }, + { id: 'quiz', name: 'quiz', displayName: 'Quiz' }, + ], + themes: [ + { id: 'base', name: 'base', displayName: 'Base Theme' }, + { id: 'flux', name: 'flux', displayName: 'Flux Theme' }, + ], + locales: [ + { code: 'en', name: 'English' }, + { code: 'es', name: 'Spanish' }, + ], + licenses: [ + { id: 'cc-by', name: 'CC BY 4.0' }, + ], + translations: { + en: { 'Hello': 'Hello' }, + es: { 'Hello': 'Hola' }, + }, + apiParameters: { + routes: { test: '/api/test' }, + }, + }; + + mockDataProvider = { + getInstalledIdevices: vi.fn(), + getInstalledThemes: vi.fn(), + getApiParameters: vi.fn(), + getUploadLimits: vi.fn(), + }; + + adapter = new StaticCatalogAdapter(mockBundleData, mockDataProvider); + + // Mock fetch + global.fetch = vi.fn(); + }); + + describe('constructor', () => { + it('should store bundle data and dataProvider', () => { + expect(adapter.bundle).toBe(mockBundleData); + expect(adapter.dataProvider).toBe(mockDataProvider); + }); + + it('should initialize cache', () => { + expect(adapter._cache).toBeInstanceOf(Map); + expect(adapter._cache.size).toBe(0); + }); + + it('should default to empty bundle if not provided', () => { + const adapterWithoutBundle = new StaticCatalogAdapter(); + expect(adapterWithoutBundle.bundle).toEqual({}); + }); + }); + + describe('_getData', () => { + it('should return cached data if available', async () => { + adapter._cache.set('testKey', 'cachedValue'); + const result = await adapter._getData('testKey'); + expect(result).toBe('cachedValue'); + }); + + it('should return bundle data and cache it', async () => { + const result = await adapter._getData('idevices'); + expect(result).toEqual(mockBundleData.idevices); + expect(adapter._cache.get('idevices')).toEqual(mockBundleData.idevices); + }); + + it('should return fallback if not in bundle or dataProvider', async () => { + const result = await adapter._getData('nonexistent', 'fallbackValue'); + expect(result).toBe('fallbackValue'); + }); + }); + + describe('getIDevices', () => { + it('should return idevices from bundle', async () => { + const result = await adapter.getIDevices(); + expect(result).toEqual(mockBundleData.idevices); + }); + + it('should fallback to dataProvider if not in bundle', async () => { + const adapterWithoutBundle = new StaticCatalogAdapter({}, mockDataProvider); + mockDataProvider.getInstalledIdevices.mockResolvedValue([{ id: 'fromProvider' }]); + + const result = await adapterWithoutBundle.getIDevices(); + + expect(mockDataProvider.getInstalledIdevices).toHaveBeenCalled(); + expect(result).toEqual([{ id: 'fromProvider' }]); + }); + + it('should return empty array if no data available', async () => { + const adapterWithoutData = new StaticCatalogAdapter(); + const result = await adapterWithoutData.getIDevices(); + expect(result).toEqual([]); + }); + }); + + describe('getThemes', () => { + it('should return themes from bundle', async () => { + const result = await adapter.getThemes(); + expect(result).toEqual(mockBundleData.themes); + }); + + it('should fallback to dataProvider if not in bundle', async () => { + const adapterWithoutBundle = new StaticCatalogAdapter({}, mockDataProvider); + mockDataProvider.getInstalledThemes.mockResolvedValue([{ id: 'themeFromProvider' }]); + + const result = await adapterWithoutBundle.getThemes(); + + expect(mockDataProvider.getInstalledThemes).toHaveBeenCalled(); + expect(result).toEqual([{ id: 'themeFromProvider' }]); + }); + + it('should return empty array if no data available', async () => { + const adapterWithoutData = new StaticCatalogAdapter(); + const result = await adapterWithoutData.getThemes(); + expect(result).toEqual([]); + }); + }); + + describe('getLocales', () => { + it('should return locales from bundle', async () => { + const result = await adapter.getLocales(); + expect(result).toEqual(mockBundleData.locales); + }); + + it('should return default locales if not in bundle', async () => { + const adapterWithoutBundle = new StaticCatalogAdapter(); + const result = await adapterWithoutBundle.getLocales(); + + expect(result).toBeInstanceOf(Array); + expect(result.length).toBeGreaterThan(0); + expect(result.some(l => l.code === 'en')).toBe(true); + }); + }); + + describe('getTranslations', () => { + it('should return translations from bundle', async () => { + const result = await adapter.getTranslations('es'); + expect(result).toEqual({ 'Hello': 'Hola' }); + }); + + it('should try fetching translations if not in bundle', async () => { + const adapterWithoutTranslations = new StaticCatalogAdapter({}); + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ 'Test': 'Prueba' }), + }); + + const result = await adapterWithoutTranslations.getTranslations('es'); + + expect(global.fetch).toHaveBeenCalledWith('./translations/es.json'); + expect(result).toEqual({ 'Test': 'Prueba' }); + }); + + it('should return empty object if fetch fails', async () => { + const adapterWithoutTranslations = new StaticCatalogAdapter({}); + global.fetch.mockRejectedValue(new Error('Network error')); + + const result = await adapterWithoutTranslations.getTranslations('fr'); + + expect(result).toEqual({}); + }); + }); + + describe('getIDevice', () => { + it('should find iDevice by id', async () => { + const result = await adapter.getIDevice('text'); + expect(result).toEqual({ id: 'text', name: 'text', displayName: 'Free Text' }); + }); + + it('should find iDevice by name', async () => { + const result = await adapter.getIDevice('quiz'); + expect(result).toEqual({ id: 'quiz', name: 'quiz', displayName: 'Quiz' }); + }); + + it('should return null if not found', async () => { + const result = await adapter.getIDevice('nonexistent'); + expect(result).toBeNull(); + }); + }); + + describe('getTheme', () => { + it('should find theme by id', async () => { + const result = await adapter.getTheme('base'); + expect(result).toEqual({ id: 'base', name: 'base', displayName: 'Base Theme' }); + }); + + it('should return null if not found', async () => { + const result = await adapter.getTheme('nonexistent'); + expect(result).toBeNull(); + }); + }); + + describe('getLicenses', () => { + it('should return licenses from bundle', async () => { + const result = await adapter.getLicenses(); + expect(result).toEqual(mockBundleData.licenses); + }); + + it('should return default licenses if not in bundle', async () => { + const adapterWithoutBundle = new StaticCatalogAdapter(); + const result = await adapterWithoutBundle.getLicenses(); + + expect(result).toBeInstanceOf(Array); + expect(result.length).toBeGreaterThan(0); + expect(result.some(l => l.id === 'cc-by')).toBe(true); + }); + }); + + describe('getExportFormats', () => { + it('should return default export formats', async () => { + const result = await adapter.getExportFormats(); + + expect(result).toBeInstanceOf(Array); + expect(result.some(f => f.id === 'html5')).toBe(true); + expect(result.some(f => f.id === 'scorm12')).toBe(true); + expect(result.some(f => f.id === 'epub3')).toBe(true); + }); + }); + + describe('getApiParameters', () => { + it('should return apiParameters from bundle', async () => { + const adapterWithoutProvider = new StaticCatalogAdapter(mockBundleData); + const result = await adapterWithoutProvider.getApiParameters(); + expect(result).toEqual(mockBundleData.apiParameters); + }); + + it('should use dataProvider if available', async () => { + mockDataProvider.getApiParameters.mockResolvedValue({ routes: { fromProvider: '/test' } }); + + const result = await adapter.getApiParameters(); + + expect(mockDataProvider.getApiParameters).toHaveBeenCalled(); + expect(result).toEqual({ routes: { fromProvider: '/test' } }); + }); + }); + + describe('getUploadLimits', () => { + it('should return sensible defaults', async () => { + const adapterWithoutProvider = new StaticCatalogAdapter(mockBundleData); + const result = await adapterWithoutProvider.getUploadLimits(); + + expect(result.maxFileSize).toBe(100 * 1024 * 1024); + expect(result.maxFileSizeFormatted).toBe('100 MB'); + }); + + it('should use dataProvider if available', async () => { + mockDataProvider.getUploadLimits.mockResolvedValue({ maxFileSize: 50000000 }); + + const result = await adapter.getUploadLimits(); + + expect(mockDataProvider.getUploadLimits).toHaveBeenCalled(); + expect(result).toEqual({ maxFileSize: 50000000 }); + }); + }); + + describe('getTemplates', () => { + it('should return empty templates in static mode', async () => { + const result = await adapter.getTemplates('es'); + expect(result).toEqual({ templates: [], locale: 'en' }); + }); + }); + + describe('getChangelog', () => { + it('should fetch local CHANGELOG.md', async () => { + global.fetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve('# Changelog\n## v1.0.0'), + }); + + const result = await adapter.getChangelog(); + + expect(global.fetch).toHaveBeenCalledWith('./CHANGELOG.md'); + expect(result).toBe('# Changelog\n## v1.0.0'); + }); + + it('should return empty string on fetch error', async () => { + global.fetch.mockRejectedValue(new Error('Network error')); + + const result = await adapter.getChangelog(); + + expect(result).toBe(''); + }); + }); + + describe('getComponentHtmlTemplate', () => { + it('should return empty template in static mode', async () => { + const result = await adapter.getComponentHtmlTemplate('comp-123'); + expect(result).toEqual({ responseMessage: 'OK', htmlTemplate: '' }); + }); + }); + + describe('createTheme', () => { + it('should return NOT_SUPPORTED', async () => { + const result = await adapter.createTheme({ name: 'test' }); + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED' }); + }); + }); + + describe('updateTheme', () => { + it('should return NOT_SUPPORTED', async () => { + const result = await adapter.updateTheme('theme-id', { name: 'updated' }); + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED' }); + }); + }); + + describe('getSaveHtmlView', () => { + it('should return empty htmlView in static mode', async () => { + const result = await adapter.getSaveHtmlView('comp-456'); + expect(result).toEqual({ responseMessage: 'OK', htmlView: '' }); + }); + }); + + describe('getIdevicesBySessionId', () => { + it('should return NOT_SUPPORTED with empty idevices', async () => { + const result = await adapter.getIdevicesBySessionId('session-789'); + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED', idevices: [] }); + }); + }); +}); diff --git a/public/app/core/adapters/static/StaticCloudStorageAdapter.js b/public/app/core/adapters/static/StaticCloudStorageAdapter.js new file mode 100644 index 000000000..a483e0fc2 --- /dev/null +++ b/public/app/core/adapters/static/StaticCloudStorageAdapter.js @@ -0,0 +1,65 @@ +/** + * StaticCloudStorageAdapter - Static/offline implementation of CloudStoragePort. + * Cloud storage is not supported in offline mode since it requires + * server-side OAuth and API integration. + */ +import { CloudStoragePort } from '../../ports/CloudStoragePort.js'; + +export class StaticCloudStorageAdapter extends CloudStoragePort { + /** + * @inheritdoc + */ + async getGoogleDriveLoginUrl() { + // Cloud storage not supported in static mode + return { responseMessage: 'NOT_SUPPORTED', url: null }; + } + + /** + * @inheritdoc + */ + async getGoogleDriveFolders() { + // Cloud storage not supported in static mode + return { responseMessage: 'NOT_SUPPORTED', folders: [] }; + } + + /** + * @inheritdoc + */ + async uploadToGoogleDrive() { + // Cloud storage not supported in static mode + return { responseMessage: 'NOT_SUPPORTED' }; + } + + /** + * @inheritdoc + */ + async getDropboxLoginUrl() { + // Cloud storage not supported in static mode + return { responseMessage: 'NOT_SUPPORTED', url: null }; + } + + /** + * @inheritdoc + */ + async getDropboxFolders() { + // Cloud storage not supported in static mode + return { responseMessage: 'NOT_SUPPORTED', folders: [] }; + } + + /** + * @inheritdoc + */ + async uploadToDropbox() { + // Cloud storage not supported in static mode + return { responseMessage: 'NOT_SUPPORTED' }; + } + + /** + * @inheritdoc + */ + isSupported() { + return false; + } +} + +export default StaticCloudStorageAdapter; diff --git a/public/app/core/adapters/static/StaticCloudStorageAdapter.test.js b/public/app/core/adapters/static/StaticCloudStorageAdapter.test.js new file mode 100644 index 000000000..d5c5e35fc --- /dev/null +++ b/public/app/core/adapters/static/StaticCloudStorageAdapter.test.js @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { StaticCloudStorageAdapter } from './StaticCloudStorageAdapter.js'; + +describe('StaticCloudStorageAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new StaticCloudStorageAdapter(); + }); + + describe('getGoogleDriveLoginUrl', () => { + it('should return NOT_SUPPORTED', async () => { + const result = await adapter.getGoogleDriveLoginUrl(); + + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED', url: null }); + }); + }); + + describe('getGoogleDriveFolders', () => { + it('should return NOT_SUPPORTED with empty folders', async () => { + const result = await adapter.getGoogleDriveFolders(); + + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED', folders: [] }); + }); + }); + + describe('uploadToGoogleDrive', () => { + it('should return NOT_SUPPORTED', async () => { + const result = await adapter.uploadToGoogleDrive({ folderId: '123' }); + + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED' }); + }); + }); + + describe('getDropboxLoginUrl', () => { + it('should return NOT_SUPPORTED', async () => { + const result = await adapter.getDropboxLoginUrl(); + + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED', url: null }); + }); + }); + + describe('getDropboxFolders', () => { + it('should return NOT_SUPPORTED with empty folders', async () => { + const result = await adapter.getDropboxFolders(); + + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED', folders: [] }); + }); + }); + + describe('uploadToDropbox', () => { + it('should return NOT_SUPPORTED', async () => { + const result = await adapter.uploadToDropbox({ path: '/folder' }); + + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED' }); + }); + }); + + describe('isSupported', () => { + it('should return false', () => { + expect(adapter.isSupported()).toBe(false); + }); + }); +}); diff --git a/public/app/core/adapters/static/StaticContentAdapter.js b/public/app/core/adapters/static/StaticContentAdapter.js new file mode 100644 index 000000000..195366a14 --- /dev/null +++ b/public/app/core/adapters/static/StaticContentAdapter.js @@ -0,0 +1,118 @@ +/** + * StaticContentAdapter - Static/offline implementation of ContentPort. + * In static mode, content operations are handled locally via Yjs. + * These methods return success and let Yjs handle the actual changes. + */ +import { ContentPort } from '../../ports/ContentPort.js'; + +export class StaticContentAdapter extends ContentPort { + /** + * @param {Object} [dataProvider] - Optional DataProvider instance + */ + constructor(dataProvider = null) { + super(); + this.dataProvider = dataProvider; + } + + /** + * @inheritdoc + * In static mode, page save is handled by Yjs sync. + */ + async savePage(params) { + // In static mode, Yjs handles all content synchronization + return { responseMessage: 'OK' }; + } + + /** + * @inheritdoc + * In static mode, page reorder is handled by Yjs sync. + */ + async reorderPage(params) { + // In static mode, Yjs handles all content synchronization + return { responseMessage: 'OK' }; + } + + /** + * @inheritdoc + * In static mode, page clone is handled locally. + */ + async clonePage(params) { + // In static mode, Yjs handles all content synchronization + return { responseMessage: 'OK' }; + } + + /** + * @inheritdoc + * In static mode, page delete is handled by Yjs sync. + */ + async deletePage(pageId) { + // In static mode, Yjs handles all content synchronization + return { responseMessage: 'OK' }; + } + + /** + * @inheritdoc + * In static mode, block reorder is handled by Yjs sync. + */ + async reorderBlock(params) { + // In static mode, Yjs handles all content synchronization + return { responseMessage: 'OK' }; + } + + /** + * @inheritdoc + * In static mode, block delete is handled by Yjs sync. + */ + async deleteBlock(blockId) { + // In static mode, Yjs handles all content synchronization + return { responseMessage: 'OK' }; + } + + /** + * @inheritdoc + * In static mode, iDevice reorder is handled by Yjs sync. + */ + async reorderIdevice(params) { + // In static mode, Yjs handles all content synchronization + return { responseMessage: 'OK' }; + } + + /** + * @inheritdoc + * In static mode, iDevice save is handled by Yjs sync. + */ + async saveIdevice(params) { + // In static mode, Yjs handles all content synchronization + return { responseMessage: 'OK' }; + } + + /** + * @inheritdoc + * In static mode, iDevice clone is handled locally. + */ + async cloneIdevice(params) { + // In static mode, Yjs handles all content synchronization + return { responseMessage: 'OK' }; + } + + /** + * @inheritdoc + * In static mode, iDevice delete is handled by Yjs sync. + */ + async deleteIdevice(ideviceId) { + // In static mode, Yjs handles all content synchronization + return { responseMessage: 'OK' }; + } + + /** + * @inheritdoc + * In static mode, generic send returns success. + */ + async send(endpointId, params) { + // In static mode, most endpoints are not available + console.warn(`[StaticContentAdapter] Endpoint ${endpointId} not available in offline mode`); + return { responseMessage: 'OK' }; + } +} + +export default StaticContentAdapter; diff --git a/public/app/core/adapters/static/StaticContentAdapter.test.js b/public/app/core/adapters/static/StaticContentAdapter.test.js new file mode 100644 index 000000000..b2eed1351 --- /dev/null +++ b/public/app/core/adapters/static/StaticContentAdapter.test.js @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { StaticContentAdapter } from './StaticContentAdapter.js'; + +describe('StaticContentAdapter', () => { + let adapter; + let mockDataProvider; + + beforeEach(() => { + mockDataProvider = { + get: vi.fn(), + set: vi.fn(), + }; + + adapter = new StaticContentAdapter(mockDataProvider); + }); + + describe('constructor', () => { + it('should store dataProvider', () => { + expect(adapter.dataProvider).toBe(mockDataProvider); + }); + + it('should allow null dataProvider', () => { + const adapterWithoutProvider = new StaticContentAdapter(); + expect(adapterWithoutProvider.dataProvider).toBeNull(); + }); + }); + + describe('savePage', () => { + it('should return OK (Yjs handles sync)', async () => { + const result = await adapter.savePage({ pageId: '123', title: 'Test' }); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('reorderPage', () => { + it('should return OK (Yjs handles sync)', async () => { + const result = await adapter.reorderPage({ order: [1, 2, 3] }); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('clonePage', () => { + it('should return OK (Yjs handles sync)', async () => { + const result = await adapter.clonePage({ pageId: '123' }); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('deletePage', () => { + it('should return OK (Yjs handles sync)', async () => { + const result = await adapter.deletePage('page-123'); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('reorderBlock', () => { + it('should return OK (Yjs handles sync)', async () => { + const result = await adapter.reorderBlock({ order: [1, 2] }); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('deleteBlock', () => { + it('should return OK (Yjs handles sync)', async () => { + const result = await adapter.deleteBlock('block-123'); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('reorderIdevice', () => { + it('should return OK (Yjs handles sync)', async () => { + const result = await adapter.reorderIdevice({ order: [1, 2] }); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('saveIdevice', () => { + it('should return OK (Yjs handles sync)', async () => { + const result = await adapter.saveIdevice({ ideviceId: '123', content: 'test' }); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('cloneIdevice', () => { + it('should return OK (Yjs handles sync)', async () => { + const result = await adapter.cloneIdevice({ ideviceId: '123' }); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('deleteIdevice', () => { + it('should return OK (Yjs handles sync)', async () => { + const result = await adapter.deleteIdevice('idevice-123'); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('send', () => { + it('should return OK and warn about offline mode', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await adapter.send('some_endpoint', { data: 'test' }); + + expect(result).toEqual({ responseMessage: 'OK' }); + expect(consoleSpy).toHaveBeenCalledWith( + '[StaticContentAdapter] Endpoint some_endpoint not available in offline mode', + ); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/public/app/core/adapters/static/StaticExportAdapter.js b/public/app/core/adapters/static/StaticExportAdapter.js new file mode 100644 index 000000000..25a17dca2 --- /dev/null +++ b/public/app/core/adapters/static/StaticExportAdapter.js @@ -0,0 +1,115 @@ +/** + * StaticExportAdapter - Static/offline implementation of ExportPort. + * Uses client-side export (JSZip) for all operations. + */ +import { ExportPort } from '../../ports/ExportPort.js'; + +export class StaticExportAdapter extends ExportPort { + /** + * @param {Object} [options] + * @param {Function} [options.getExporter] - Function to get ElpxExporter instance + */ + constructor(options = {}) { + super(); + this.getExporter = options.getExporter || (() => window.eXeLearning?.app?.elpxExporter); + } + + /** + * @inheritdoc + */ + async exportAs(format, projectData, options = {}) { + const exporter = this.getExporter(); + if (!exporter) { + throw new Error('Exporter not available'); + } + + // Delegate to the existing client-side exporter + switch (format) { + case 'html5': + return exporter.exportToHtml5(projectData, options); + case 'scorm12': + return exporter.exportToScorm12(projectData, options); + case 'scorm2004': + return exporter.exportToScorm2004(projectData, options); + case 'ims': + return exporter.exportToIms(projectData, options); + case 'epub3': + return exporter.exportToEpub3(projectData, options); + case 'xliff': + return exporter.exportToXliff(projectData, options); + default: + throw new Error(`Unsupported export format: ${format}`); + } + } + + /** + * @inheritdoc + */ + async getSupportedFormats() { + return [ + { id: 'html5', name: 'Website (HTML5)', extension: 'zip' }, + { id: 'scorm12', name: 'SCORM 1.2', extension: 'zip' }, + { id: 'scorm2004', name: 'SCORM 2004', extension: 'zip' }, + { id: 'ims', name: 'IMS Content Package', extension: 'zip' }, + { id: 'epub3', name: 'ePub 3', extension: 'epub' }, + { id: 'xliff', name: 'XLIFF', extension: 'xliff' }, + ]; + } + + /** + * @inheritdoc + */ + async isFormatSupported(format) { + const formats = await this.getSupportedFormats(); + return formats.some((f) => f.id === format); + } + + /** + * @inheritdoc + */ + async generatePreview(projectData) { + // In static mode, preview is generated client-side + const exporter = this.getExporter(); + if (!exporter) { + throw new Error('Exporter not available'); + } + + return exporter.generatePreviewHtml(projectData); + } + + /** + * @inheritdoc + */ + async exportAsElpx(projectData, assets) { + const exporter = this.getExporter(); + if (!exporter) { + throw new Error('Exporter not available'); + } + + return exporter.exportToElpx(projectData, assets); + } + + /** + * Get preview URL for a session. + * In static mode, preview is generated client-side. + * @inheritdoc + */ + async getPreviewUrl(sessionId) { + return { + responseMessage: 'OK', + clientSidePreview: true, + }; + } + + /** + * Download iDevice/block content as file. + * Not supported in static mode. + * @inheritdoc + */ + async downloadIDevice(sessionId, blockId, ideviceId) { + console.warn('[StaticExportAdapter] iDevice download not supported in static mode'); + return { url: '', response: '', responseMessage: 'NOT_SUPPORTED_IN_STATIC_MODE' }; + } +} + +export default StaticExportAdapter; diff --git a/public/app/core/adapters/static/StaticExportAdapter.test.js b/public/app/core/adapters/static/StaticExportAdapter.test.js new file mode 100644 index 000000000..406858ee0 --- /dev/null +++ b/public/app/core/adapters/static/StaticExportAdapter.test.js @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { StaticExportAdapter } from './StaticExportAdapter.js'; + +describe('StaticExportAdapter', () => { + let adapter; + let mockExporter; + + beforeEach(() => { + mockExporter = { + exportToHtml5: vi.fn().mockResolvedValue({ blob: 'html5' }), + exportToScorm12: vi.fn().mockResolvedValue({ blob: 'scorm12' }), + exportToScorm2004: vi.fn().mockResolvedValue({ blob: 'scorm2004' }), + exportToIms: vi.fn().mockResolvedValue({ blob: 'ims' }), + exportToEpub3: vi.fn().mockResolvedValue({ blob: 'epub3' }), + exportToXliff: vi.fn().mockResolvedValue({ blob: 'xliff' }), + generatePreviewHtml: vi.fn().mockResolvedValue(''), + exportToElpx: vi.fn().mockResolvedValue({ blob: 'elpx' }), + }; + + adapter = new StaticExportAdapter({ + getExporter: () => mockExporter, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should accept custom getExporter function', () => { + const customGetter = () => mockExporter; + const customAdapter = new StaticExportAdapter({ getExporter: customGetter }); + expect(customAdapter.getExporter()).toBe(mockExporter); + }); + + it('should default to window.eXeLearning.app.elpxExporter', () => { + window.eXeLearning = { app: { elpxExporter: mockExporter } }; + + const defaultAdapter = new StaticExportAdapter(); + expect(defaultAdapter.getExporter()).toBe(mockExporter); + + delete window.eXeLearning; + }); + }); + + describe('exportAs', () => { + it('should export as html5', async () => { + const result = await adapter.exportAs('html5', { title: 'Test' }, { option: true }); + + expect(mockExporter.exportToHtml5).toHaveBeenCalledWith( + { title: 'Test' }, + { option: true }, + ); + expect(result).toEqual({ blob: 'html5' }); + }); + + it('should export as scorm12', async () => { + const result = await adapter.exportAs('scorm12', { title: 'Test' }); + + expect(mockExporter.exportToScorm12).toHaveBeenCalled(); + expect(result).toEqual({ blob: 'scorm12' }); + }); + + it('should export as scorm2004', async () => { + const result = await adapter.exportAs('scorm2004', { title: 'Test' }); + + expect(mockExporter.exportToScorm2004).toHaveBeenCalled(); + expect(result).toEqual({ blob: 'scorm2004' }); + }); + + it('should export as ims', async () => { + const result = await adapter.exportAs('ims', { title: 'Test' }); + + expect(mockExporter.exportToIms).toHaveBeenCalled(); + expect(result).toEqual({ blob: 'ims' }); + }); + + it('should export as epub3', async () => { + const result = await adapter.exportAs('epub3', { title: 'Test' }); + + expect(mockExporter.exportToEpub3).toHaveBeenCalled(); + expect(result).toEqual({ blob: 'epub3' }); + }); + + it('should export as xliff', async () => { + const result = await adapter.exportAs('xliff', { title: 'Test' }); + + expect(mockExporter.exportToXliff).toHaveBeenCalled(); + expect(result).toEqual({ blob: 'xliff' }); + }); + + it('should throw for unsupported format', async () => { + await expect(adapter.exportAs('unknown', {})).rejects.toThrow('Unsupported export format'); + }); + + it('should throw if exporter not available', async () => { + const adapterWithoutExporter = new StaticExportAdapter({ + getExporter: () => null, + }); + + await expect(adapterWithoutExporter.exportAs('html5', {})).rejects.toThrow( + 'Exporter not available', + ); + }); + }); + + describe('getSupportedFormats', () => { + it('should return list of supported formats', async () => { + const formats = await adapter.getSupportedFormats(); + + expect(formats).toBeInstanceOf(Array); + expect(formats.length).toBe(6); + expect(formats.some(f => f.id === 'html5')).toBe(true); + expect(formats.some(f => f.id === 'scorm12')).toBe(true); + expect(formats.some(f => f.id === 'scorm2004')).toBe(true); + expect(formats.some(f => f.id === 'ims')).toBe(true); + expect(formats.some(f => f.id === 'epub3')).toBe(true); + expect(formats.some(f => f.id === 'xliff')).toBe(true); + }); + + it('should include name and extension for each format', async () => { + const formats = await adapter.getSupportedFormats(); + + formats.forEach(format => { + expect(format).toHaveProperty('id'); + expect(format).toHaveProperty('name'); + expect(format).toHaveProperty('extension'); + }); + }); + }); + + describe('isFormatSupported', () => { + it('should return true for supported format', async () => { + const result = await adapter.isFormatSupported('html5'); + expect(result).toBe(true); + }); + + it('should return false for unsupported format', async () => { + const result = await adapter.isFormatSupported('unknown'); + expect(result).toBe(false); + }); + }); + + describe('generatePreview', () => { + it('should generate preview using exporter', async () => { + const result = await adapter.generatePreview({ title: 'Test' }); + + expect(mockExporter.generatePreviewHtml).toHaveBeenCalledWith({ title: 'Test' }); + expect(result).toBe(''); + }); + + it('should throw if exporter not available', async () => { + const adapterWithoutExporter = new StaticExportAdapter({ + getExporter: () => null, + }); + + await expect(adapterWithoutExporter.generatePreview({})).rejects.toThrow( + 'Exporter not available', + ); + }); + }); + + describe('exportAsElpx', () => { + it('should export as ELPX using exporter', async () => { + const result = await adapter.exportAsElpx({ title: 'Test' }, { assets: [] }); + + expect(mockExporter.exportToElpx).toHaveBeenCalledWith( + { title: 'Test' }, + { assets: [] }, + ); + expect(result).toEqual({ blob: 'elpx' }); + }); + + it('should throw if exporter not available', async () => { + const adapterWithoutExporter = new StaticExportAdapter({ + getExporter: () => null, + }); + + await expect(adapterWithoutExporter.exportAsElpx({}, {})).rejects.toThrow( + 'Exporter not available', + ); + }); + }); + + describe('getPreviewUrl', () => { + it('should return client-side preview indicator', async () => { + const result = await adapter.getPreviewUrl('session-123'); + + expect(result).toEqual({ + responseMessage: 'OK', + clientSidePreview: true, + }); + }); + }); + + describe('downloadIDevice', () => { + it('should return NOT_SUPPORTED in static mode', async () => { + const result = await adapter.downloadIDevice('s', 'b', 'i'); + + expect(result).toEqual({ + url: '', + response: '', + responseMessage: 'NOT_SUPPORTED_IN_STATIC_MODE', + }); + }); + }); +}); diff --git a/public/app/core/adapters/static/StaticLinkValidationAdapter.js b/public/app/core/adapters/static/StaticLinkValidationAdapter.js new file mode 100644 index 000000000..5abcd16a2 --- /dev/null +++ b/public/app/core/adapters/static/StaticLinkValidationAdapter.js @@ -0,0 +1,62 @@ +/** + * StaticLinkValidationAdapter - Static/offline implementation of LinkValidationPort. + * Link validation is not supported in offline mode since it requires + * server-side connectivity checks. + */ +import { LinkValidationPort } from '../../ports/LinkValidationPort.js'; + +export class StaticLinkValidationAdapter extends LinkValidationPort { + /** + * @inheritdoc + */ + async getSessionBrokenLinks() { + // Link validation not supported in static mode + return { responseMessage: 'OK', brokenLinks: [] }; + } + + /** + * @inheritdoc + */ + async extractLinks() { + // Link extraction not supported in static mode + return { responseMessage: 'OK', links: [], totalLinks: 0 }; + } + + /** + * @inheritdoc + */ + getValidationStreamUrl() { + // No stream URL in static mode + return null; + } + + /** + * @inheritdoc + */ + async getPageBrokenLinks() { + return { brokenLinks: [] }; + } + + /** + * @inheritdoc + */ + async getBlockBrokenLinks() { + return { brokenLinks: [] }; + } + + /** + * @inheritdoc + */ + async getIdeviceBrokenLinks() { + return { brokenLinks: [] }; + } + + /** + * @inheritdoc + */ + isSupported() { + return false; + } +} + +export default StaticLinkValidationAdapter; diff --git a/public/app/core/adapters/static/StaticLinkValidationAdapter.test.js b/public/app/core/adapters/static/StaticLinkValidationAdapter.test.js new file mode 100644 index 000000000..76d48d18d --- /dev/null +++ b/public/app/core/adapters/static/StaticLinkValidationAdapter.test.js @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { StaticLinkValidationAdapter } from './StaticLinkValidationAdapter.js'; + +describe('StaticLinkValidationAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new StaticLinkValidationAdapter(); + }); + + describe('getSessionBrokenLinks', () => { + it('should return OK with empty brokenLinks', async () => { + const result = await adapter.getSessionBrokenLinks({ sessionId: '123' }); + + expect(result).toEqual({ responseMessage: 'OK', brokenLinks: [] }); + }); + }); + + describe('extractLinks', () => { + it('should return OK with empty links', async () => { + const result = await adapter.extractLinks({ content: '' }); + + expect(result).toEqual({ responseMessage: 'OK', links: [], totalLinks: 0 }); + }); + }); + + describe('getValidationStreamUrl', () => { + it('should return null', () => { + const url = adapter.getValidationStreamUrl(); + + expect(url).toBeNull(); + }); + }); + + describe('getPageBrokenLinks', () => { + it('should return empty brokenLinks', async () => { + const result = await adapter.getPageBrokenLinks('page-123'); + + expect(result).toEqual({ brokenLinks: [] }); + }); + }); + + describe('getBlockBrokenLinks', () => { + it('should return empty brokenLinks', async () => { + const result = await adapter.getBlockBrokenLinks('block-456'); + + expect(result).toEqual({ brokenLinks: [] }); + }); + }); + + describe('getIdeviceBrokenLinks', () => { + it('should return empty brokenLinks', async () => { + const result = await adapter.getIdeviceBrokenLinks('idevice-789'); + + expect(result).toEqual({ brokenLinks: [] }); + }); + }); + + describe('isSupported', () => { + it('should return false', () => { + expect(adapter.isSupported()).toBe(false); + }); + }); +}); diff --git a/public/app/core/adapters/static/StaticPlatformIntegrationAdapter.js b/public/app/core/adapters/static/StaticPlatformIntegrationAdapter.js new file mode 100644 index 000000000..fae250cc3 --- /dev/null +++ b/public/app/core/adapters/static/StaticPlatformIntegrationAdapter.js @@ -0,0 +1,33 @@ +/** + * StaticPlatformIntegrationAdapter - Static/offline implementation of PlatformIntegrationPort. + * Platform integration is not supported in offline mode since it requires + * server-side communication with external LMS platforms. + */ +import { PlatformIntegrationPort } from '../../ports/PlatformIntegrationPort.js'; + +export class StaticPlatformIntegrationAdapter extends PlatformIntegrationPort { + /** + * @inheritdoc + */ + async uploadElp() { + // Platform integration not supported in static mode + return { responseMessage: 'NOT_SUPPORTED' }; + } + + /** + * @inheritdoc + */ + async openElp() { + // Platform integration not supported in static mode + return { responseMessage: 'NOT_SUPPORTED' }; + } + + /** + * @inheritdoc + */ + isSupported() { + return false; + } +} + +export default StaticPlatformIntegrationAdapter; diff --git a/public/app/core/adapters/static/StaticPlatformIntegrationAdapter.test.js b/public/app/core/adapters/static/StaticPlatformIntegrationAdapter.test.js new file mode 100644 index 000000000..0adc67c8f --- /dev/null +++ b/public/app/core/adapters/static/StaticPlatformIntegrationAdapter.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { StaticPlatformIntegrationAdapter } from './StaticPlatformIntegrationAdapter.js'; + +describe('StaticPlatformIntegrationAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new StaticPlatformIntegrationAdapter(); + }); + + describe('uploadElp', () => { + it('should return NOT_SUPPORTED', async () => { + const result = await adapter.uploadElp({ elpData: 'base64data' }); + + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED' }); + }); + }); + + describe('openElp', () => { + it('should return NOT_SUPPORTED', async () => { + const result = await adapter.openElp({ resourceId: '123' }); + + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED' }); + }); + }); + + describe('isSupported', () => { + it('should return false', () => { + expect(adapter.isSupported()).toBe(false); + }); + }); +}); diff --git a/public/app/core/adapters/static/StaticProjectRepository.js b/public/app/core/adapters/static/StaticProjectRepository.js new file mode 100644 index 000000000..8db31cd8d --- /dev/null +++ b/public/app/core/adapters/static/StaticProjectRepository.js @@ -0,0 +1,500 @@ +/** + * StaticProjectRepository - Static/offline implementation of ProjectRepositoryPort. + * Uses IndexedDB for project persistence. + */ +import { ProjectRepositoryPort } from '../../ports/ProjectRepositoryPort.js'; +import { StorageError, NotFoundError } from '../../errors.js'; + +export class StaticProjectRepository extends ProjectRepositoryPort { + /** + * @param {Object} [options] + * @param {string} [options.dbPrefix] - Prefix for IndexedDB database names + */ + constructor(options = {}) { + super(); + this.dbPrefix = options.dbPrefix || 'exelearning-project-'; + } + + /** + * @inheritdoc + */ + async list() { + try { + // Check if indexedDB.databases() is supported + if (!window.indexedDB?.databases) { + console.log( + '[StaticProjectRepository] indexedDB.databases() not supported' + ); + return []; + } + + const databases = await window.indexedDB.databases(); + const projectDatabases = databases.filter((db) => + db.name?.startsWith(this.dbPrefix) + ); + + const projects = await Promise.all( + projectDatabases.map(async (db) => { + const uuid = db.name.replace(this.dbPrefix, ''); + const metadata = await this._getProjectMetadata(uuid); + return { + uuid, + id: uuid, + title: + metadata?.title || + `Local Project (${uuid.substring(0, 8)}...)`, + updatedAt: + metadata?.updatedAt || new Date().toISOString(), + isLocal: true, + }; + }) + ); + + // Sort by updatedAt descending + return projects.sort( + (a, b) => new Date(b.updatedAt) - new Date(a.updatedAt) + ); + } catch (error) { + console.error('[StaticProjectRepository] list error:', error); + return []; + } + } + + /** + * @inheritdoc + */ + async get(id) { + try { + const metadata = await this._getProjectMetadata(id); + if (!metadata) { + return null; + } + return { + uuid: id, + id, + ...metadata, + isLocal: true, + }; + } catch (error) { + console.error('[StaticProjectRepository] get error:', error); + return null; + } + } + + /** + * @inheritdoc + */ + async create(data) { + const uuid = data.uuid || crypto.randomUUID(); + const now = new Date().toISOString(); + + const metadata = { + uuid, + title: data.title || 'Untitled Project', + createdAt: now, + updatedAt: now, + }; + + await this._saveProjectMetadata(uuid, metadata); + + return { + uuid, + id: uuid, + ...metadata, + isLocal: true, + }; + } + + /** + * @inheritdoc + */ + async update(id, data) { + const existing = await this.get(id); + if (!existing) { + throw new NotFoundError('project', id); + } + + const metadata = { + ...existing, + ...data, + updatedAt: new Date().toISOString(), + }; + + await this._saveProjectMetadata(id, metadata); + + return { + uuid: id, + id, + ...metadata, + isLocal: true, + }; + } + + /** + * @inheritdoc + */ + async delete(id) { + try { + const dbName = `${this.dbPrefix}${id}`; + // Delete the IndexedDB database + await new Promise((resolve, reject) => { + const request = window.indexedDB.deleteDatabase(dbName); + request.onsuccess = () => resolve(); + request.onerror = () => + reject(new StorageError(`Failed to delete database: ${dbName}`)); + request.onblocked = () => { + console.warn(`[StaticProjectRepository] Database deletion blocked: ${dbName}`); + resolve(); // Continue anyway + }; + }); + } catch (error) { + console.error('[StaticProjectRepository] delete error:', error); + throw new StorageError(`Failed to delete project: ${error.message}`); + } + } + + /** + * @inheritdoc + */ + async getRecent(limit = 3) { + const projects = await this.list(); + return projects.slice(0, limit); + } + + /** + * @inheritdoc + */ + async exists(id) { + const project = await this.get(id); + return project !== null; + } + + /** + * Get project metadata from Yjs IndexedDB. + * @private + */ + async _getProjectMetadata(uuid) { + try { + const dbName = `${this.dbPrefix}${uuid}`; + const db = await this._openDatabase(dbName); + if (!db) { + return null; + } + + // Try to get metadata from the updates store + const metadata = await this._getFromStore(db, 'metadata', 'project'); + db.close(); + + return metadata; + } catch (error) { + console.error('[StaticProjectRepository] _getProjectMetadata error:', error); + return null; + } + } + + /** + * Save project metadata to Yjs IndexedDB. + * @private + */ + async _saveProjectMetadata(uuid, metadata) { + const dbName = `${this.dbPrefix}${uuid}`; + + return new Promise((resolve, reject) => { + const request = window.indexedDB.open(dbName, 1); + + request.onerror = () => { + reject(new StorageError(`Failed to open database: ${dbName}`)); + }; + + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains('metadata')) { + db.createObjectStore('metadata', { keyPath: 'key' }); + } + }; + + request.onsuccess = (event) => { + const db = event.target.result; + try { + const tx = db.transaction(['metadata'], 'readwrite'); + const store = tx.objectStore('metadata'); + store.put({ key: 'project', ...metadata }); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(new StorageError('Transaction failed')); + }; + } catch (error) { + db.close(); + reject(error); + } + }; + }); + } + + /** + * Open an IndexedDB database. + * @private + */ + async _openDatabase(dbName) { + return new Promise((resolve) => { + const request = window.indexedDB.open(dbName); + + request.onerror = () => { + resolve(null); + }; + + request.onsuccess = (event) => { + resolve(event.target.result); + }; + }); + } + + /** + * Get value from object store. + * @private + */ + async _getFromStore(db, storeName, key) { + return new Promise((resolve) => { + try { + if (!db.objectStoreNames.contains(storeName)) { + resolve(null); + return; + } + + const tx = db.transaction([storeName], 'readonly'); + const store = tx.objectStore(storeName); + const request = store.get(key); + + request.onsuccess = () => { + resolve(request.result || null); + }; + + request.onerror = () => { + resolve(null); + }; + } catch { + resolve(null); + } + }); + } + + /** + * Save a project - handled by Yjs/IndexedDB in static mode. + * @inheritdoc + */ + async save(sessionId, params) { + console.log('[StaticProjectRepository] save handled by Yjs/IndexedDB'); + return { responseMessage: 'OK', staticMode: true }; + } + + /** + * Autosave - handled by Yjs persistence in static mode. + * @inheritdoc + */ + async autoSave(sessionId, params) { + console.log('[StaticProjectRepository] autosave handled by Yjs persistence'); + // No-op - Yjs handles persistence automatically + } + + /** + * Save as new project - handled client-side in static mode. + * @inheritdoc + */ + async saveAs(sessionId, params) { + console.log('[StaticProjectRepository] saveAs handled client-side'); + return { responseMessage: 'OK', staticMode: true }; + } + + /** + * Duplicate project - not supported in static mode. + * @inheritdoc + */ + async duplicate(id) { + console.warn('[StaticProjectRepository] duplicate not supported in static mode'); + return { responseMessage: 'NOT_SUPPORTED_IN_STATIC_MODE' }; + } + + /** + * Get last updated - returns metadata from IndexedDB. + * @inheritdoc + */ + async getLastUpdated(id) { + try { + const metadata = await this._getProjectMetadata(id); + return { + lastUpdated: metadata?.updatedAt || null, + staticMode: true, + }; + } catch (error) { + console.error('[StaticProjectRepository] getLastUpdated error:', error); + return { lastUpdated: null }; + } + } + + /** + * Get concurrent users - always empty in static mode (no collaboration). + * @inheritdoc + */ + async getConcurrentUsers(id, versionId, sessionId) { + return { users: [], staticMode: true }; + } + + /** + * Close session - no-op in static mode. + * @inheritdoc + */ + async closeSession(params) { + console.log('[StaticProjectRepository] closeSession - no-op in static mode'); + return { responseMessage: 'OK', staticMode: true }; + } + + /** + * Join session - always available in static mode (single user). + * @inheritdoc + */ + async joinSession(sessionId) { + return { available: true, staticMode: true }; + } + + /** + * Check current users - always 0 in static mode. + * @inheritdoc + */ + async checkCurrentUsers(params) { + return { responseMessage: 'OK', currentUsers: 0, staticMode: true }; + } + + /** + * Open file - handled client-side via JSZip in static mode. + * @inheritdoc + */ + async openFile(fileName) { + // In static mode, file operations are handled client-side + return { responseMessage: 'OK', odeSessionId: window.eXeLearning?.projectId }; + } + + /** + * Open local file - handled client-side in static mode. + * @inheritdoc + */ + async openLocalFile(data) { + return { responseMessage: 'OK', odeSessionId: window.eXeLearning?.projectId }; + } + + /** + * Open large local file - handled client-side in static mode. + * @inheritdoc + */ + async openLargeLocalFile(data) { + return { responseMessage: 'OK', odeSessionId: window.eXeLearning?.projectId }; + } + + /** + * Get local properties - returns empty in static mode. + * @inheritdoc + */ + async getLocalProperties(data) { + return { responseMessage: 'OK', properties: {} }; + } + + /** + * Get local components - returns empty in static mode. + * @inheritdoc + */ + async getLocalComponents(data) { + return { responseMessage: 'OK', components: [] }; + } + + /** + * Import to root - handled client-side via JSZip in static mode. + * @inheritdoc + */ + async importToRoot(data) { + return { responseMessage: 'OK' }; + } + + /** + * Import to root from local - not supported in static mode. + * @inheritdoc + */ + async importToRootFromLocal(payload) { + return { responseMessage: 'OK' }; + } + + /** + * Import as child - not supported in static mode. + * @inheritdoc + */ + async importAsChild(navId, payload) { + return { responseMessage: 'OK' }; + } + + /** + * Open multiple local files - not supported in static mode. + * @inheritdoc + */ + async openMultipleLocalFiles(data) { + return { responseMessage: 'OK' }; + } + + /** + * Delete by date - not applicable in static mode. + * @inheritdoc + */ + async deleteByDate(params) { + return { responseMessage: 'OK' }; + } + + /** + * Clean autosaves - not applicable in static mode. + * @inheritdoc + */ + async cleanAutosaves(params) { + return { responseMessage: 'OK' }; + } + + /** + * Get structure - managed by Yjs locally in static mode. + * @inheritdoc + */ + async getStructure(versionId, sessionId) { + // In static mode, structure is managed by Yjs locally + return { structure: null }; + } + + /** + * Get properties - returns bundled config in static mode. + * @inheritdoc + */ + async getProperties(sessionId) { + // Properties come from bundled config in static mode + const config = window.eXeLearning?.app?.apiCallManager?.parameters; + return { + responseMessage: 'OK', + properties: config?.odeProjectSyncPropertiesConfig || {}, + }; + } + + /** + * Save properties - handled by Yjs locally in static mode. + * @inheritdoc + */ + async saveProperties(params) { + // In static mode, properties are saved via Yjs + return { responseMessage: 'OK' }; + } + + /** + * Get used files - not supported in static mode. + * @inheritdoc + */ + async getUsedFiles(params) { + return { responseMessage: 'OK', usedFiles: [] }; + } +} + +export default StaticProjectRepository; diff --git a/public/app/core/adapters/static/StaticProjectRepository.test.js b/public/app/core/adapters/static/StaticProjectRepository.test.js new file mode 100644 index 000000000..6495c3e87 --- /dev/null +++ b/public/app/core/adapters/static/StaticProjectRepository.test.js @@ -0,0 +1,453 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { StaticProjectRepository } from './StaticProjectRepository.js'; + +describe('StaticProjectRepository', () => { + let repo; + let mockIndexedDB; + + beforeEach(() => { + repo = new StaticProjectRepository(); + + // Mock IndexedDB + mockIndexedDB = { + databases: vi.fn(), + open: vi.fn(), + deleteDatabase: vi.fn(), + }; + window.indexedDB = mockIndexedDB; + + // Mock crypto.randomUUID + crypto.randomUUID = vi.fn().mockReturnValue('test-uuid-123'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should set default dbPrefix', () => { + expect(repo.dbPrefix).toBe('exelearning-project-'); + }); + + it('should allow custom dbPrefix', () => { + const customRepo = new StaticProjectRepository({ dbPrefix: 'custom-' }); + expect(customRepo.dbPrefix).toBe('custom-'); + }); + }); + + describe('list', () => { + it('should return empty array if databases() not supported', async () => { + delete window.indexedDB.databases; + + const result = await repo.list(); + + expect(result).toEqual([]); + }); + + it('should return empty array on error', async () => { + mockIndexedDB.databases.mockRejectedValue(new Error('Error')); + + const result = await repo.list(); + + expect(result).toEqual([]); + }); + + it('should filter and return project databases', async () => { + mockIndexedDB.databases.mockResolvedValue([ + { name: 'exelearning-project-uuid1' }, + { name: 'exelearning-project-uuid2' }, + { name: 'other-database' }, + ]); + + // Mock _getProjectMetadata + repo._getProjectMetadata = vi.fn() + .mockResolvedValueOnce({ title: 'Project 1', updatedAt: '2024-01-02' }) + .mockResolvedValueOnce({ title: 'Project 2', updatedAt: '2024-01-01' }); + + const result = await repo.list(); + + expect(result).toHaveLength(2); + expect(result[0].title).toBe('Project 1'); + expect(result[1].title).toBe('Project 2'); + }); + }); + + describe('get', () => { + it('should return null if project not found', async () => { + repo._getProjectMetadata = vi.fn().mockResolvedValue(null); + + const result = await repo.get('nonexistent'); + + expect(result).toBeNull(); + }); + + it('should return project with metadata', async () => { + repo._getProjectMetadata = vi.fn().mockResolvedValue({ + title: 'Test Project', + updatedAt: '2024-01-01', + }); + + const result = await repo.get('test-uuid'); + + expect(result).toEqual({ + uuid: 'test-uuid', + id: 'test-uuid', + title: 'Test Project', + updatedAt: '2024-01-01', + isLocal: true, + }); + }); + + it('should return null on error', async () => { + repo._getProjectMetadata = vi.fn().mockRejectedValue(new Error('Error')); + + const result = await repo.get('test-uuid'); + + expect(result).toBeNull(); + }); + }); + + describe('create', () => { + it('should create project with UUID', async () => { + repo._saveProjectMetadata = vi.fn().mockResolvedValue(); + + const result = await repo.create({ title: 'New Project' }); + + expect(result.uuid).toBe('test-uuid-123'); + expect(result.title).toBe('New Project'); + expect(result.isLocal).toBe(true); + expect(repo._saveProjectMetadata).toHaveBeenCalled(); + }); + + it('should use provided UUID if given', async () => { + repo._saveProjectMetadata = vi.fn().mockResolvedValue(); + + const result = await repo.create({ uuid: 'custom-uuid', title: 'Test' }); + + expect(result.uuid).toBe('custom-uuid'); + }); + + it('should default title to Untitled Project', async () => { + repo._saveProjectMetadata = vi.fn().mockResolvedValue(); + + const result = await repo.create({}); + + expect(result.title).toBe('Untitled Project'); + }); + }); + + describe('update', () => { + it('should throw if project not found', async () => { + repo.get = vi.fn().mockResolvedValue(null); + + await expect(repo.update('nonexistent', {})).rejects.toThrow(); + }); + + it('should update project metadata', async () => { + repo.get = vi.fn().mockResolvedValue({ + uuid: 'test-uuid', + title: 'Old Title', + }); + repo._saveProjectMetadata = vi.fn().mockResolvedValue(); + + const result = await repo.update('test-uuid', { title: 'New Title' }); + + expect(result.title).toBe('New Title'); + expect(result.isLocal).toBe(true); + }); + }); + + describe('delete', () => { + it('should delete project database', async () => { + const mockRequest = { + onsuccess: null, + onerror: null, + onblocked: null, + }; + mockIndexedDB.deleteDatabase.mockReturnValue(mockRequest); + + const deletePromise = repo.delete('test-uuid'); + mockRequest.onsuccess(); + + await deletePromise; + + expect(mockIndexedDB.deleteDatabase).toHaveBeenCalledWith('exelearning-project-test-uuid'); + }); + + it('should handle blocked deletion', async () => { + const mockRequest = { + onsuccess: null, + onerror: null, + onblocked: null, + }; + mockIndexedDB.deleteDatabase.mockReturnValue(mockRequest); + + const deletePromise = repo.delete('test-uuid'); + mockRequest.onblocked(); + + await deletePromise; + }); + }); + + describe('getRecent', () => { + it('should return limited number of projects', async () => { + repo.list = vi.fn().mockResolvedValue([ + { id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, + ]); + + const result = await repo.getRecent(2); + + expect(result).toHaveLength(2); + }); + + it('should default to 3 projects', async () => { + repo.list = vi.fn().mockResolvedValue([ + { id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, + ]); + + const result = await repo.getRecent(); + + expect(result).toHaveLength(3); + }); + }); + + describe('exists', () => { + it('should return true if project exists', async () => { + repo.get = vi.fn().mockResolvedValue({ id: 'test' }); + + const result = await repo.exists('test'); + + expect(result).toBe(true); + }); + + it('should return false if project not found', async () => { + repo.get = vi.fn().mockResolvedValue(null); + + const result = await repo.exists('nonexistent'); + + expect(result).toBe(false); + }); + }); + + describe('save', () => { + it('should return OK for static mode', async () => { + const result = await repo.save('session', {}); + + expect(result).toEqual({ responseMessage: 'OK', staticMode: true }); + }); + }); + + describe('autoSave', () => { + it('should be no-op in static mode', async () => { + await repo.autoSave('session', {}); + // Should not throw + }); + }); + + describe('saveAs', () => { + it('should return OK for static mode', async () => { + const result = await repo.saveAs('session', {}); + + expect(result).toEqual({ responseMessage: 'OK', staticMode: true }); + }); + }); + + describe('duplicate', () => { + it('should return NOT_SUPPORTED', async () => { + const result = await repo.duplicate('123'); + + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED_IN_STATIC_MODE' }); + }); + }); + + describe('getLastUpdated', () => { + it('should return metadata updatedAt', async () => { + repo._getProjectMetadata = vi.fn().mockResolvedValue({ + updatedAt: '2024-01-01', + }); + + const result = await repo.getLastUpdated('test-uuid'); + + expect(result).toEqual({ lastUpdated: '2024-01-01', staticMode: true }); + }); + + it('should return null on error', async () => { + repo._getProjectMetadata = vi.fn().mockRejectedValue(new Error('Error')); + + const result = await repo.getLastUpdated('test-uuid'); + + expect(result).toEqual({ lastUpdated: null }); + }); + }); + + describe('getConcurrentUsers', () => { + it('should return empty users in static mode', async () => { + const result = await repo.getConcurrentUsers('id', 'v', 's'); + + expect(result).toEqual({ users: [], staticMode: true }); + }); + }); + + describe('closeSession', () => { + it('should return OK in static mode', async () => { + const result = await repo.closeSession({}); + + expect(result).toEqual({ responseMessage: 'OK', staticMode: true }); + }); + }); + + describe('joinSession', () => { + it('should return available true in static mode', async () => { + const result = await repo.joinSession('session'); + + expect(result).toEqual({ available: true, staticMode: true }); + }); + }); + + describe('checkCurrentUsers', () => { + it('should return 0 users in static mode', async () => { + const result = await repo.checkCurrentUsers({}); + + expect(result).toEqual({ responseMessage: 'OK', currentUsers: 0, staticMode: true }); + }); + }); + + describe('openFile', () => { + it('should return OK in static mode', async () => { + window.eXeLearning = { projectId: 'current-project' }; + + const result = await repo.openFile('test.elp'); + + expect(result).toEqual({ responseMessage: 'OK', odeSessionId: 'current-project' }); + + delete window.eXeLearning; + }); + }); + + describe('openLocalFile', () => { + it('should return OK in static mode', async () => { + const result = await repo.openLocalFile({}); + + expect(result.responseMessage).toBe('OK'); + }); + }); + + describe('openLargeLocalFile', () => { + it('should return OK in static mode', async () => { + const result = await repo.openLargeLocalFile({}); + + expect(result.responseMessage).toBe('OK'); + }); + }); + + describe('getLocalProperties', () => { + it('should return empty properties', async () => { + const result = await repo.getLocalProperties({}); + + expect(result).toEqual({ responseMessage: 'OK', properties: {} }); + }); + }); + + describe('getLocalComponents', () => { + it('should return empty components', async () => { + const result = await repo.getLocalComponents({}); + + expect(result).toEqual({ responseMessage: 'OK', components: [] }); + }); + }); + + describe('importToRoot', () => { + it('should return OK in static mode', async () => { + const result = await repo.importToRoot({}); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('importToRootFromLocal', () => { + it('should return OK in static mode', async () => { + const result = await repo.importToRootFromLocal({}); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('importAsChild', () => { + it('should return OK in static mode', async () => { + const result = await repo.importAsChild('nav', {}); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('openMultipleLocalFiles', () => { + it('should return OK in static mode', async () => { + const result = await repo.openMultipleLocalFiles({}); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('deleteByDate', () => { + it('should return OK in static mode', async () => { + const result = await repo.deleteByDate({}); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('cleanAutosaves', () => { + it('should return OK in static mode', async () => { + const result = await repo.cleanAutosaves({}); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('getStructure', () => { + it('should return null structure in static mode', async () => { + const result = await repo.getStructure('v', 's'); + + expect(result).toEqual({ structure: null }); + }); + }); + + describe('getProperties', () => { + it('should return bundled config properties', async () => { + window.eXeLearning = { + app: { + apiCallManager: { + parameters: { + odeProjectSyncPropertiesConfig: { key: 'value' }, + }, + }, + }, + }; + + const result = await repo.getProperties('session'); + + expect(result).toEqual({ + responseMessage: 'OK', + properties: { key: 'value' }, + }); + + delete window.eXeLearning; + }); + }); + + describe('saveProperties', () => { + it('should return OK in static mode', async () => { + const result = await repo.saveProperties({}); + + expect(result).toEqual({ responseMessage: 'OK' }); + }); + }); + + describe('getUsedFiles', () => { + it('should return empty files', async () => { + const result = await repo.getUsedFiles({}); + + expect(result).toEqual({ responseMessage: 'OK', usedFiles: [] }); + }); + }); +}); diff --git a/public/app/core/adapters/static/StaticSharingAdapter.js b/public/app/core/adapters/static/StaticSharingAdapter.js new file mode 100644 index 000000000..171dd6a80 --- /dev/null +++ b/public/app/core/adapters/static/StaticSharingAdapter.js @@ -0,0 +1,57 @@ +/** + * StaticSharingAdapter - Static/offline implementation of SharingPort. + * Sharing is not supported in offline mode since it requires + * server-side user management and real-time collaboration. + */ +import { SharingPort } from '../../ports/SharingPort.js'; + +export class StaticSharingAdapter extends SharingPort { + /** + * @inheritdoc + */ + async getProject(_projectId) { + // Sharing not supported in static mode + return { responseMessage: 'NOT_SUPPORTED' }; + } + + /** + * @inheritdoc + */ + async updateVisibility(_projectId, _visibility) { + // Sharing not supported in static mode + return { responseMessage: 'NOT_SUPPORTED' }; + } + + /** + * @inheritdoc + */ + async addCollaborator(_projectId, _email, _role) { + // Sharing not supported in static mode + return { responseMessage: 'NOT_SUPPORTED' }; + } + + /** + * @inheritdoc + */ + async removeCollaborator(_projectId, _userId) { + // Sharing not supported in static mode + return { responseMessage: 'NOT_SUPPORTED' }; + } + + /** + * @inheritdoc + */ + async transferOwnership(_projectId, _newOwnerId) { + // Sharing not supported in static mode + return { responseMessage: 'NOT_SUPPORTED' }; + } + + /** + * @inheritdoc + */ + isSupported() { + return false; + } +} + +export default StaticSharingAdapter; diff --git a/public/app/core/adapters/static/StaticSharingAdapter.test.js b/public/app/core/adapters/static/StaticSharingAdapter.test.js new file mode 100644 index 000000000..80346a9ca --- /dev/null +++ b/public/app/core/adapters/static/StaticSharingAdapter.test.js @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { StaticSharingAdapter } from './StaticSharingAdapter.js'; + +describe('StaticSharingAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new StaticSharingAdapter(); + }); + + describe('getProject', () => { + it('should return NOT_SUPPORTED', async () => { + const result = await adapter.getProject('123'); + + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED' }); + }); + }); + + describe('updateVisibility', () => { + it('should return NOT_SUPPORTED', async () => { + const result = await adapter.updateVisibility('123', 'public'); + + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED' }); + }); + }); + + describe('addCollaborator', () => { + it('should return NOT_SUPPORTED', async () => { + const result = await adapter.addCollaborator('123', 'user@example.com', 'editor'); + + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED' }); + }); + }); + + describe('removeCollaborator', () => { + it('should return NOT_SUPPORTED', async () => { + const result = await adapter.removeCollaborator('123', 'user-456'); + + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED' }); + }); + }); + + describe('transferOwnership', () => { + it('should return NOT_SUPPORTED', async () => { + const result = await adapter.transferOwnership('123', 'new-owner'); + + expect(result).toEqual({ responseMessage: 'NOT_SUPPORTED' }); + }); + }); + + describe('isSupported', () => { + it('should return false', () => { + expect(adapter.isSupported()).toBe(false); + }); + }); +}); diff --git a/public/app/core/adapters/static/StaticUserPreferenceAdapter.js b/public/app/core/adapters/static/StaticUserPreferenceAdapter.js new file mode 100644 index 000000000..c7903f091 --- /dev/null +++ b/public/app/core/adapters/static/StaticUserPreferenceAdapter.js @@ -0,0 +1,164 @@ +/** + * StaticUserPreferenceAdapter - Static/offline implementation of UserPreferencePort. + * Uses localStorage for preference persistence. + */ +import { UserPreferencePort } from '../../ports/UserPreferencePort.js'; + +export class StaticUserPreferenceAdapter extends UserPreferencePort { + /** + * @param {Object} [options] + * @param {Object} [options.defaultPreferences] - Default preferences config + * @param {string} [options.storageKey] - localStorage key prefix + */ + constructor(options = {}) { + super(); + this.defaultPreferences = options.defaultPreferences || {}; + this.storageKey = options.storageKey || 'exelearning_user_preferences'; + this.lopdKey = 'exelearning_lopd_accepted'; + } + + /** + * Get default preferences from bundled config. + * @private + */ + _getDefaultPreferences() { + // Minimal fallback defaults to prevent crashes + const FALLBACK_DEFAULTS = { + locale: { title: 'Language', value: 'en', type: 'select' }, + advancedMode: { title: 'Advanced Mode', value: 'false', type: 'checkbox' }, + versionControl: { title: 'Version Control', value: 'false', type: 'checkbox' }, + }; + + // Try to get from bundled parameters first (multiple possible locations) + const bundled = + window.eXeLearning?.app?.apiCall?.parameters?.userPreferencesConfig || + window.eXeLearning?.app?.api?.parameters?.userPreferencesConfig; + + if (bundled) { + const result = JSON.parse(JSON.stringify(bundled)); + // Ensure required fields have valid values (not null) + for (const key of Object.keys(FALLBACK_DEFAULTS)) { + if (!result[key] || result[key].value === null || result[key].value === undefined) { + result[key] = { ...FALLBACK_DEFAULTS[key] }; + } + } + return result; + } + + // Return default preferences if available + if (Object.keys(this.defaultPreferences).length > 0) { + const result = JSON.parse(JSON.stringify(this.defaultPreferences)); + // Ensure required fields have valid values + for (const key of Object.keys(FALLBACK_DEFAULTS)) { + if (!result[key] || result[key].value === null || result[key].value === undefined) { + result[key] = { ...FALLBACK_DEFAULTS[key] }; + } + } + return result; + } + + // Return fallback defaults + return { ...FALLBACK_DEFAULTS }; + } + + /** + * Load stored preferences from localStorage. + * @private + */ + _loadStoredPreferences() { + try { + const stored = localStorage.getItem(this.storageKey); + return stored ? JSON.parse(stored) : {}; + } catch (error) { + console.warn('[StaticUserPreferenceAdapter] Failed to load preferences:', error); + return {}; + } + } + + /** + * Save preferences to localStorage. + * @private + */ + _saveStoredPreferences(prefs) { + try { + localStorage.setItem(this.storageKey, JSON.stringify(prefs)); + return true; + } catch (error) { + console.warn('[StaticUserPreferenceAdapter] Failed to save preferences:', error); + return false; + } + } + + /** + * @inheritdoc + */ + async getPreferences() { + const defaultPrefs = this._getDefaultPreferences(); + const stored = this._loadStoredPreferences(); + + // Merge stored values into defaults + for (const [key, value] of Object.entries(stored)) { + if (defaultPrefs[key]) { + defaultPrefs[key].value = value; + } + } + + return { + userPreferences: defaultPrefs, + }; + } + + /** + * @inheritdoc + */ + async savePreferences(params) { + const stored = this._loadStoredPreferences(); + Object.assign(stored, params); + const success = this._saveStoredPreferences(stored); + return { success }; + } + + /** + * @inheritdoc + */ + async acceptLopd() { + try { + localStorage.setItem(this.lopdKey, 'true'); + return { success: true }; + } catch (error) { + console.warn('[StaticUserPreferenceAdapter] Failed to save LOPD acceptance:', error); + return { success: false }; + } + } + + /** + * @inheritdoc + */ + async isLopdAccepted() { + try { + return localStorage.getItem(this.lopdKey) === 'true'; + } catch { + return false; + } + } + + /** + * @inheritdoc + */ + async getPreference(key, defaultValue = null) { + const stored = this._loadStoredPreferences(); + return stored[key] !== undefined ? stored[key] : defaultValue; + } + + /** + * @inheritdoc + */ + async setPreference(key, value) { + const stored = this._loadStoredPreferences(); + stored[key] = value; + const success = this._saveStoredPreferences(stored); + return { success }; + } +} + +export default StaticUserPreferenceAdapter; diff --git a/public/app/core/adapters/static/StaticUserPreferenceAdapter.test.js b/public/app/core/adapters/static/StaticUserPreferenceAdapter.test.js new file mode 100644 index 000000000..9348f86b0 --- /dev/null +++ b/public/app/core/adapters/static/StaticUserPreferenceAdapter.test.js @@ -0,0 +1,268 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { StaticUserPreferenceAdapter } from './StaticUserPreferenceAdapter.js'; + +describe('StaticUserPreferenceAdapter', () => { + let adapter; + let localStorageData; + + beforeEach(() => { + localStorageData = {}; + + // Mock localStorage + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn((key) => localStorageData[key] || null), + setItem: vi.fn((key, value) => { localStorageData[key] = value; }), + removeItem: vi.fn((key) => { delete localStorageData[key]; }), + }, + writable: true, + }); + + adapter = new StaticUserPreferenceAdapter(); + }); + + afterEach(() => { + delete window.eXeLearning; + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should set default storageKey', () => { + expect(adapter.storageKey).toBe('exelearning_user_preferences'); + }); + + it('should set default lopdKey', () => { + expect(adapter.lopdKey).toBe('exelearning_lopd_accepted'); + }); + + it('should allow custom options', () => { + const customAdapter = new StaticUserPreferenceAdapter({ + defaultPreferences: { theme: { value: 'dark' } }, + storageKey: 'custom_prefs', + }); + + expect(customAdapter.storageKey).toBe('custom_prefs'); + expect(customAdapter.defaultPreferences.theme.value).toBe('dark'); + }); + }); + + describe('_getDefaultPreferences', () => { + it('should return fallback defaults if no bundled config', () => { + const defaults = adapter._getDefaultPreferences(); + + expect(defaults.locale).toBeDefined(); + expect(defaults.advancedMode).toBeDefined(); + expect(defaults.versionControl).toBeDefined(); + }); + + it('should use bundled preferences if available', () => { + window.eXeLearning = { + app: { + apiCall: { + parameters: { + userPreferencesConfig: { + locale: { title: 'Language', value: 'es', type: 'select' }, + customPref: { title: 'Custom', value: 'test', type: 'text' }, + }, + }, + }, + }, + }; + + const defaults = adapter._getDefaultPreferences(); + + expect(defaults.locale.value).toBe('es'); + expect(defaults.customPref.value).toBe('test'); + }); + + it('should use defaultPreferences from constructor', () => { + const customAdapter = new StaticUserPreferenceAdapter({ + defaultPreferences: { + locale: { title: 'Language', value: 'fr', type: 'select' }, + advancedMode: { title: 'Advanced', value: 'true', type: 'checkbox' }, + versionControl: { title: 'Version', value: 'false', type: 'checkbox' }, + }, + }); + + const defaults = customAdapter._getDefaultPreferences(); + + expect(defaults.locale.value).toBe('fr'); + }); + + it('should fill in missing required fields with fallbacks', () => { + window.eXeLearning = { + app: { + apiCall: { + parameters: { + userPreferencesConfig: { + locale: { title: 'Language', value: null, type: 'select' }, + }, + }, + }, + }, + }; + + const defaults = adapter._getDefaultPreferences(); + + // Should use fallback for null value + expect(defaults.locale.value).toBe('en'); + }); + }); + + describe('_loadStoredPreferences', () => { + it('should load preferences from localStorage', () => { + localStorageData.exelearning_user_preferences = JSON.stringify({ locale: 'es' }); + + const stored = adapter._loadStoredPreferences(); + + expect(stored.locale).toBe('es'); + }); + + it('should return empty object if nothing stored', () => { + const stored = adapter._loadStoredPreferences(); + + expect(stored).toEqual({}); + }); + + it('should return empty object on parse error', () => { + localStorageData.exelearning_user_preferences = 'invalid json'; + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const stored = adapter._loadStoredPreferences(); + + expect(stored).toEqual({}); + consoleSpy.mockRestore(); + }); + }); + + describe('_saveStoredPreferences', () => { + it('should save preferences to localStorage', () => { + const result = adapter._saveStoredPreferences({ locale: 'es' }); + + expect(result).toBe(true); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + 'exelearning_user_preferences', + JSON.stringify({ locale: 'es' }) + ); + }); + + it('should return false on error', () => { + window.localStorage.setItem.mockImplementation(() => { + throw new Error('Storage full'); + }); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = adapter._saveStoredPreferences({ locale: 'es' }); + + expect(result).toBe(false); + consoleSpy.mockRestore(); + }); + }); + + describe('getPreferences', () => { + it('should merge stored values into defaults', async () => { + localStorageData.exelearning_user_preferences = JSON.stringify({ locale: 'es' }); + + const result = await adapter.getPreferences(); + + expect(result.userPreferences.locale.value).toBe('es'); + expect(result.userPreferences.advancedMode).toBeDefined(); + }); + + it('should return defaults if nothing stored', async () => { + const result = await adapter.getPreferences(); + + expect(result.userPreferences.locale).toBeDefined(); + expect(result.userPreferences.advancedMode).toBeDefined(); + }); + }); + + describe('savePreferences', () => { + it('should merge and save preferences', async () => { + localStorageData.exelearning_user_preferences = JSON.stringify({ locale: 'en' }); + + const result = await adapter.savePreferences({ theme: 'dark' }); + + expect(result.success).toBe(true); + const saved = JSON.parse(localStorageData.exelearning_user_preferences); + expect(saved.locale).toBe('en'); + expect(saved.theme).toBe('dark'); + }); + }); + + describe('acceptLopd', () => { + it('should save LOPD acceptance to localStorage', async () => { + const result = await adapter.acceptLopd(); + + expect(result.success).toBe(true); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + 'exelearning_lopd_accepted', + 'true' + ); + }); + + it('should return success false on error', async () => { + window.localStorage.setItem.mockImplementation(() => { + throw new Error('Storage full'); + }); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await adapter.acceptLopd(); + + expect(result.success).toBe(false); + consoleSpy.mockRestore(); + }); + }); + + describe('isLopdAccepted', () => { + it('should return true if LOPD is accepted', async () => { + localStorageData.exelearning_lopd_accepted = 'true'; + + const result = await adapter.isLopdAccepted(); + + expect(result).toBe(true); + }); + + it('should return false if LOPD not accepted', async () => { + const result = await adapter.isLopdAccepted(); + + expect(result).toBe(false); + }); + + it('should return false on error', async () => { + window.localStorage.getItem.mockImplementation(() => { + throw new Error('Storage error'); + }); + + const result = await adapter.isLopdAccepted(); + + expect(result).toBe(false); + }); + }); + + describe('getPreference', () => { + it('should return stored preference value', async () => { + localStorageData.exelearning_user_preferences = JSON.stringify({ locale: 'es' }); + + const result = await adapter.getPreference('locale'); + + expect(result).toBe('es'); + }); + + it('should return default value if not found', async () => { + const result = await adapter.getPreference('nonexistent', 'default'); + + expect(result).toBe('default'); + }); + }); + + describe('setPreference', () => { + it('should save single preference', async () => { + const result = await adapter.setPreference('locale', 'es'); + + expect(result.success).toBe(true); + const saved = JSON.parse(localStorageData.exelearning_user_preferences); + expect(saved.locale).toBe('es'); + }); + }); +}); diff --git a/public/app/core/adapters/static/index.js b/public/app/core/adapters/static/index.js new file mode 100644 index 000000000..fb8c979f6 --- /dev/null +++ b/public/app/core/adapters/static/index.js @@ -0,0 +1,14 @@ +/** + * Static adapters - IndexedDB/local implementations of port interfaces. + * Used in offline/static mode where no server is available. + */ +export { StaticProjectRepository } from './StaticProjectRepository.js'; +export { StaticCatalogAdapter } from './StaticCatalogAdapter.js'; +export { StaticAssetAdapter } from './StaticAssetAdapter.js'; +export { NullCollaborationAdapter } from './NullCollaborationAdapter.js'; +export { StaticExportAdapter } from './StaticExportAdapter.js'; +export { StaticLinkValidationAdapter } from './StaticLinkValidationAdapter.js'; +export { StaticCloudStorageAdapter } from './StaticCloudStorageAdapter.js'; +export { StaticPlatformIntegrationAdapter } from './StaticPlatformIntegrationAdapter.js'; +export { StaticSharingAdapter } from './StaticSharingAdapter.js'; +export { StaticContentAdapter } from './StaticContentAdapter.js'; diff --git a/public/app/core/errors.js b/public/app/core/errors.js new file mode 100644 index 000000000..030eb880a --- /dev/null +++ b/public/app/core/errors.js @@ -0,0 +1,137 @@ +/** + * Application Error Types + * Structured errors for better error handling across the application. + */ + +/** + * Base application error. + */ +export class AppError extends Error { + /** + * @param {string} message - Error message + * @param {string} code - Error code for programmatic handling + */ + constructor(message, code = 'APP_ERROR') { + super(message); + this.name = 'AppError'; + this.code = code; + } +} + +/** + * Network-related errors (HTTP failures, timeouts, etc.) + */ +export class NetworkError extends AppError { + /** + * @param {string} message - Error message + * @param {number} [statusCode] - HTTP status code + * @param {Object} [response] - Response data + */ + constructor(message, statusCode = null, response = null) { + super(message, 'NETWORK_ERROR'); + this.name = 'NetworkError'; + this.statusCode = statusCode; + this.response = response; + } + + /** + * Check if error is a client error (4xx). + * @returns {boolean} + */ + isClientError() { + return this.statusCode >= 400 && this.statusCode < 500; + } + + /** + * Check if error is a server error (5xx). + * @returns {boolean} + */ + isServerError() { + return this.statusCode >= 500 && this.statusCode < 600; + } +} + +/** + * Feature not available in current mode. + */ +export class FeatureDisabledError extends AppError { + /** + * @param {string} feature - Feature name + */ + constructor(feature) { + super(`Feature "${feature}" is not available in this mode`, 'FEATURE_DISABLED'); + this.name = 'FeatureDisabledError'; + this.feature = feature; + } +} + +/** + * Storage-related errors (IndexedDB, file system, etc.) + */ +export class StorageError extends AppError { + /** + * @param {string} message - Error message + * @param {Error} [cause] - Original error + */ + constructor(message, cause = null) { + super(message, 'STORAGE_ERROR'); + this.name = 'StorageError'; + this.cause = cause; + } +} + +/** + * Validation errors for user input. + */ +export class ValidationError extends AppError { + /** + * @param {string} message - Error message + * @param {Object} [fields] - Field-specific errors + */ + constructor(message, fields = {}) { + super(message, 'VALIDATION_ERROR'); + this.name = 'ValidationError'; + this.fields = fields; + } +} + +/** + * Authentication/authorization errors. + */ +export class AuthError extends AppError { + /** + * @param {string} message - Error message + * @param {boolean} [requiresLogin] - Whether user needs to log in + */ + constructor(message, requiresLogin = false) { + super(message, 'AUTH_ERROR'); + this.name = 'AuthError'; + this.requiresLogin = requiresLogin; + } +} + +/** + * Resource not found errors. + */ +export class NotFoundError extends AppError { + /** + * @param {string} resourceType - Type of resource (project, asset, etc.) + * @param {string} resourceId - Resource identifier + */ + constructor(resourceType, resourceId) { + super(`${resourceType} "${resourceId}" not found`, 'NOT_FOUND'); + this.name = 'NotFoundError'; + this.resourceType = resourceType; + this.resourceId = resourceId; + } +} + +export default { + AppError, + NetworkError, + FeatureDisabledError, + StorageError, + ValidationError, + AuthError, + NotFoundError, +}; diff --git a/public/app/core/index.js b/public/app/core/index.js new file mode 100644 index 000000000..af0a36cc6 --- /dev/null +++ b/public/app/core/index.js @@ -0,0 +1,82 @@ +/** + * Core module - Dependency injection infrastructure. + * + * This module provides the ports/adapters pattern for mode-independent code. + * Instead of checking `isStaticMode()` throughout the codebase, code should: + * + * 1. Use injected adapters (via ProviderFactory) for operations + * 2. Query capabilities for feature availability + * + * Example: + * ```javascript + * // Bootstrap (app.js) + * const factory = await ProviderFactory.create(); + * const projectRepo = factory.createProjectRepository(); + * const capabilities = factory.getCapabilities(); + * + * // Usage - mode-agnostic + * const projects = await projectRepo.list(); + * + * // Feature checking + * if (capabilities.collaboration.enabled) { + * showShareButton(); + * } + * ``` + */ + +// Configuration +export { RuntimeConfig } from './RuntimeConfig.js'; +export { Capabilities } from './Capabilities.js'; + +// Factory +export { ProviderFactory, ServerProviderFactory, StaticProviderFactory } from './ProviderFactory.js'; + +// HTTP Client +export { HttpClient } from './HttpClient.js'; + +// Errors +export { + AppError, + NetworkError, + FeatureDisabledError, + StorageError, + ValidationError, + AuthError, + NotFoundError, +} from './errors.js'; + +// Ports (interfaces) +export { ProjectRepositoryPort } from './ports/ProjectRepositoryPort.js'; +export { CatalogPort } from './ports/CatalogPort.js'; +export { AssetPort } from './ports/AssetPort.js'; +export { CollaborationPort } from './ports/CollaborationPort.js'; +export { ExportPort } from './ports/ExportPort.js'; +export { LinkValidationPort } from './ports/LinkValidationPort.js'; +export { CloudStoragePort } from './ports/CloudStoragePort.js'; +export { PlatformIntegrationPort } from './ports/PlatformIntegrationPort.js'; +export { SharingPort } from './ports/SharingPort.js'; +export { ContentPort } from './ports/ContentPort.js'; + +// Server adapters +export { ServerProjectRepository } from './adapters/server/ServerProjectRepository.js'; +export { ServerCatalogAdapter } from './adapters/server/ServerCatalogAdapter.js'; +export { ServerAssetAdapter } from './adapters/server/ServerAssetAdapter.js'; +export { ServerCollaborationAdapter } from './adapters/server/ServerCollaborationAdapter.js'; +export { ServerExportAdapter } from './adapters/server/ServerExportAdapter.js'; +export { ServerLinkValidationAdapter } from './adapters/server/ServerLinkValidationAdapter.js'; +export { ServerCloudStorageAdapter } from './adapters/server/ServerCloudStorageAdapter.js'; +export { ServerPlatformIntegrationAdapter } from './adapters/server/ServerPlatformIntegrationAdapter.js'; +export { ServerSharingAdapter } from './adapters/server/ServerSharingAdapter.js'; +export { ServerContentAdapter } from './adapters/server/ServerContentAdapter.js'; + +// Static adapters +export { StaticProjectRepository } from './adapters/static/StaticProjectRepository.js'; +export { StaticCatalogAdapter } from './adapters/static/StaticCatalogAdapter.js'; +export { StaticAssetAdapter } from './adapters/static/StaticAssetAdapter.js'; +export { NullCollaborationAdapter } from './adapters/static/NullCollaborationAdapter.js'; +export { StaticExportAdapter } from './adapters/static/StaticExportAdapter.js'; +export { StaticLinkValidationAdapter } from './adapters/static/StaticLinkValidationAdapter.js'; +export { StaticCloudStorageAdapter } from './adapters/static/StaticCloudStorageAdapter.js'; +export { StaticPlatformIntegrationAdapter } from './adapters/static/StaticPlatformIntegrationAdapter.js'; +export { StaticSharingAdapter } from './adapters/static/StaticSharingAdapter.js'; +export { StaticContentAdapter } from './adapters/static/StaticContentAdapter.js'; diff --git a/public/app/core/ports/AssetPort.js b/public/app/core/ports/AssetPort.js new file mode 100644 index 000000000..a80198b6a --- /dev/null +++ b/public/app/core/ports/AssetPort.js @@ -0,0 +1,90 @@ +/** + * AssetPort - Domain interface for asset management. + * Implemented by ServerAssetAdapter and StaticAssetAdapter. + */ +export class AssetPort { + /** + * Upload an asset. + * @param {string} projectId - Project UUID + * @param {File|Blob} file - File to upload + * @param {string} path - Destination path within project + * @returns {Promise<{url: string, path: string}>} + */ + async upload(projectId, file, path) { + throw new Error('AssetPort.upload() not implemented'); + } + + /** + * Get an asset URL. + * @param {string} projectId - Project UUID + * @param {string} path - Asset path within project + * @returns {Promise} - URL to access the asset + */ + async getUrl(projectId, path) { + throw new Error('AssetPort.getUrl() not implemented'); + } + + /** + * Get asset content as blob. + * @param {string} projectId - Project UUID + * @param {string} path - Asset path within project + * @returns {Promise} + */ + async getBlob(projectId, path) { + throw new Error('AssetPort.getBlob() not implemented'); + } + + /** + * Delete an asset. + * @param {string} projectId - Project UUID + * @param {string} path - Asset path within project + * @returns {Promise} + */ + async delete(projectId, path) { + throw new Error('AssetPort.delete() not implemented'); + } + + /** + * List assets in a project. + * @param {string} projectId - Project UUID + * @param {string} [directory] - Optional subdirectory + * @returns {Promise>} + */ + async list(projectId, directory) { + throw new Error('AssetPort.list() not implemented'); + } + + /** + * Check if an asset exists. + * @param {string} projectId - Project UUID + * @param {string} path - Asset path within project + * @returns {Promise} + */ + async exists(projectId, path) { + throw new Error('AssetPort.exists() not implemented'); + } + + /** + * Copy an asset. + * @param {string} projectId - Project UUID + * @param {string} srcPath - Source path + * @param {string} destPath - Destination path + * @returns {Promise} + */ + async copy(projectId, srcPath, destPath) { + throw new Error('AssetPort.copy() not implemented'); + } + + /** + * Move an asset. + * @param {string} projectId - Project UUID + * @param {string} srcPath - Source path + * @param {string} destPath - Destination path + * @returns {Promise} + */ + async move(projectId, srcPath, destPath) { + throw new Error('AssetPort.move() not implemented'); + } +} + +export default AssetPort; diff --git a/public/app/core/ports/CatalogPort.js b/public/app/core/ports/CatalogPort.js new file mode 100644 index 000000000..046822df6 --- /dev/null +++ b/public/app/core/ports/CatalogPort.js @@ -0,0 +1,170 @@ +/** + * CatalogPort - Domain interface for accessing catalog data. + * (iDevices, themes, locales, translations) + * Implemented by ServerCatalogAdapter and StaticCatalogAdapter. + */ +export class CatalogPort { + /** + * Get all available iDevices. + * @returns {Promise>} + */ + async getIDevices() { + throw new Error('CatalogPort.getIDevices() not implemented'); + } + + /** + * Get all available themes. + * @returns {Promise>} + */ + async getThemes() { + throw new Error('CatalogPort.getThemes() not implemented'); + } + + /** + * Get all available locales. + * @returns {Promise>} + */ + async getLocales() { + throw new Error('CatalogPort.getLocales() not implemented'); + } + + /** + * Get translations for a specific locale. + * @param {string} locale - Locale code (e.g., 'es', 'en') + * @returns {Promise} - Translation key-value pairs + */ + async getTranslations(locale) { + throw new Error('CatalogPort.getTranslations() not implemented'); + } + + /** + * Get iDevice by ID. + * @param {string} id - iDevice ID + * @returns {Promise} + */ + async getIDevice(id) { + throw new Error('CatalogPort.getIDevice() not implemented'); + } + + /** + * Get theme by ID. + * @param {string} id - Theme ID + * @returns {Promise} + */ + async getTheme(id) { + throw new Error('CatalogPort.getTheme() not implemented'); + } + + /** + * Get licenses. + * @returns {Promise} + */ + async getLicenses() { + throw new Error('CatalogPort.getLicenses() not implemented'); + } + + /** + * Get export formats. + * @returns {Promise} + */ + async getExportFormats() { + throw new Error('CatalogPort.getExportFormats() not implemented'); + } + + /** + * Get templates for a locale. + * @param {string} locale - Locale code (e.g., 'es', 'en') + * @returns {Promise<{templates: Array, locale: string}>} + */ + async getTemplates(locale) { + throw new Error('CatalogPort.getTemplates() not implemented'); + } + + /** + * Get HTML template for a component. + * @param {string} componentId - Component sync ID + * @returns {Promise<{htmlTemplate: string}>} + */ + async getComponentHtmlTemplate(componentId) { + throw new Error('CatalogPort.getComponentHtmlTemplate() not implemented'); + } + + /** + * Create a new theme (admin operation). + * @param {Object} params - Theme creation parameters + * @returns {Promise<{responseMessage: string}>} + */ + async createTheme(params) { + throw new Error('CatalogPort.createTheme() not implemented'); + } + + /** + * Update/edit an existing theme (admin operation). + * @param {string} themeDir - Theme directory name + * @param {Object} params - Theme update parameters + * @returns {Promise<{responseMessage: string}>} + */ + async updateTheme(themeDir, params) { + throw new Error('CatalogPort.updateTheme() not implemented'); + } + + /** + * Get API parameters (endpoints, configuration). + * @returns {Promise} + */ + async getApiParameters() { + throw new Error('CatalogPort.getApiParameters() not implemented'); + } + + /** + * Get changelog text. + * @returns {Promise} + */ + async getChangelog() { + throw new Error('CatalogPort.getChangelog() not implemented'); + } + + /** + * Get upload limits configuration. + * @returns {Promise<{maxFileSize: number, maxFileSizeFormatted: string}>} + */ + async getUploadLimits() { + throw new Error('CatalogPort.getUploadLimits() not implemented'); + } + + /** + * Get third-party code information. + * @returns {Promise} + */ + async getThirdPartyCode() { + throw new Error('CatalogPort.getThirdPartyCode() not implemented'); + } + + /** + * Get licenses list text. + * @returns {Promise} + */ + async getLicensesList() { + throw new Error('CatalogPort.getLicensesList() not implemented'); + } + + /** + * Get saved HTML view for a component. + * @param {string} componentId - Component sync ID + * @returns {Promise<{responseMessage: string, htmlView: string}>} + */ + async getSaveHtmlView(componentId) { + throw new Error('CatalogPort.getSaveHtmlView() not implemented'); + } + + /** + * Get iDevices by session ID (games API). + * @param {string} sessionId - ODE session ID + * @returns {Promise<{responseMessage: string, idevices: Array}>} + */ + async getIdevicesBySessionId(sessionId) { + throw new Error('CatalogPort.getIdevicesBySessionId() not implemented'); + } +} + +export default CatalogPort; diff --git a/public/app/core/ports/CloudStoragePort.js b/public/app/core/ports/CloudStoragePort.js new file mode 100644 index 000000000..73ca85683 --- /dev/null +++ b/public/app/core/ports/CloudStoragePort.js @@ -0,0 +1,72 @@ +/** + * CloudStoragePort - Domain interface for cloud storage operations. + * Handles integration with cloud storage providers (Google Drive, Dropbox, etc.). + * Implemented by ServerCloudStorageAdapter and StaticCloudStorageAdapter. + */ +export class CloudStoragePort { + /** + * Get the OAuth login URL for Google Drive. + * @returns {Promise<{responseMessage: string, url: string|null}>} + */ + async getGoogleDriveLoginUrl() { + throw new Error('CloudStoragePort.getGoogleDriveLoginUrl() not implemented'); + } + + /** + * Get folders from Google Drive account. + * @returns {Promise<{responseMessage: string, folders: Array}>} + */ + async getGoogleDriveFolders() { + throw new Error('CloudStoragePort.getGoogleDriveFolders() not implemented'); + } + + /** + * Upload a file to Google Drive. + * @param {Object} params - Upload parameters + * @param {string} params.folderId - Target folder ID + * @param {string} params.fileName - File name + * @param {Blob|File} params.file - File content + * @returns {Promise<{responseMessage: string}>} + */ + async uploadToGoogleDrive(params) { + throw new Error('CloudStoragePort.uploadToGoogleDrive() not implemented'); + } + + /** + * Get the OAuth login URL for Dropbox. + * @returns {Promise<{responseMessage: string, url: string|null}>} + */ + async getDropboxLoginUrl() { + throw new Error('CloudStoragePort.getDropboxLoginUrl() not implemented'); + } + + /** + * Get folders from Dropbox account. + * @returns {Promise<{responseMessage: string, folders: Array}>} + */ + async getDropboxFolders() { + throw new Error('CloudStoragePort.getDropboxFolders() not implemented'); + } + + /** + * Upload a file to Dropbox. + * @param {Object} params - Upload parameters + * @param {string} params.path - Target path + * @param {string} params.fileName - File name + * @param {Blob|File} params.file - File content + * @returns {Promise<{responseMessage: string}>} + */ + async uploadToDropbox(params) { + throw new Error('CloudStoragePort.uploadToDropbox() not implemented'); + } + + /** + * Check if cloud storage is supported in current mode. + * @returns {boolean} + */ + isSupported() { + return true; + } +} + +export default CloudStoragePort; diff --git a/public/app/core/ports/CollaborationPort.js b/public/app/core/ports/CollaborationPort.js new file mode 100644 index 000000000..d8759edb3 --- /dev/null +++ b/public/app/core/ports/CollaborationPort.js @@ -0,0 +1,76 @@ +/** + * CollaborationPort - Domain interface for real-time collaboration. + * Implemented by ServerCollaborationAdapter and NullCollaborationAdapter. + */ +export class CollaborationPort { + /** + * Check if collaboration is enabled. + * @returns {boolean} + */ + isEnabled() { + return false; + } + + /** + * Connect to a collaboration session for a project. + * @param {string} projectId - Project UUID + * @returns {Promise} + */ + async connect(projectId) { + throw new Error('CollaborationPort.connect() not implemented'); + } + + /** + * Disconnect from the current collaboration session. + * @returns {Promise} + */ + async disconnect() { + throw new Error('CollaborationPort.disconnect() not implemented'); + } + + /** + * Get current presence information (who's online). + * @returns {Promise>} + */ + async getPresence() { + throw new Error('CollaborationPort.getPresence() not implemented'); + } + + /** + * Update local user's presence (cursor position, selection, etc.). + * @param {Object} data - Presence data + * @returns {Promise} + */ + async updatePresence(data) { + throw new Error('CollaborationPort.updatePresence() not implemented'); + } + + /** + * Subscribe to presence changes. + * @param {Function} callback - Called when presence changes + * @returns {Function} - Unsubscribe function + */ + onPresenceChange(callback) { + throw new Error('CollaborationPort.onPresenceChange() not implemented'); + } + + /** + * Get WebSocket URL for Yjs provider. + * @returns {string|null} + */ + getWebSocketUrl() { + return null; + } + + /** + * Obtain block sync data. + * In Yjs mode, synchronization is automatic - this returns null. + * @param {Object} params - Sync parameters + * @returns {Promise<{responseMessage: string, block: Object|null}>} + */ + async obtainBlockSync(params) { + throw new Error('CollaborationPort.obtainBlockSync() not implemented'); + } +} + +export default CollaborationPort; diff --git a/public/app/core/ports/ContentPort.js b/public/app/core/ports/ContentPort.js new file mode 100644 index 000000000..e8d1c4480 --- /dev/null +++ b/public/app/core/ports/ContentPort.js @@ -0,0 +1,108 @@ +/** + * ContentPort - Domain interface for content structure operations. + * Handles pages, blocks, and iDevices manipulation. + * Implemented by ServerContentAdapter and StaticContentAdapter. + */ +export class ContentPort { + /** + * Save page data. + * @param {Object} params - Page save parameters + * @returns {Promise<{responseMessage: string}>} + */ + async savePage(params) { + throw new Error('ContentPort.savePage() not implemented'); + } + + /** + * Reorder pages. + * @param {Object} params - Reorder parameters + * @returns {Promise<{responseMessage: string}>} + */ + async reorderPage(params) { + throw new Error('ContentPort.reorderPage() not implemented'); + } + + /** + * Clone/duplicate a page. + * @param {Object} params - Clone parameters + * @returns {Promise<{responseMessage: string}>} + */ + async clonePage(params) { + throw new Error('ContentPort.clonePage() not implemented'); + } + + /** + * Delete a page. + * @param {string} pageId - Page ID to delete + * @returns {Promise<{responseMessage: string}>} + */ + async deletePage(pageId) { + throw new Error('ContentPort.deletePage() not implemented'); + } + + /** + * Reorder blocks within a page. + * @param {Object} params - Reorder parameters + * @returns {Promise<{responseMessage: string}>} + */ + async reorderBlock(params) { + throw new Error('ContentPort.reorderBlock() not implemented'); + } + + /** + * Delete a block. + * @param {string} blockId - Block ID to delete + * @returns {Promise<{responseMessage: string}>} + */ + async deleteBlock(blockId) { + throw new Error('ContentPort.deleteBlock() not implemented'); + } + + /** + * Reorder iDevices within a block. + * @param {Object} params - Reorder parameters + * @returns {Promise<{responseMessage: string}>} + */ + async reorderIdevice(params) { + throw new Error('ContentPort.reorderIdevice() not implemented'); + } + + /** + * Save iDevice data. + * @param {Object} params - iDevice save parameters + * @returns {Promise<{responseMessage: string}>} + */ + async saveIdevice(params) { + throw new Error('ContentPort.saveIdevice() not implemented'); + } + + /** + * Clone/duplicate an iDevice. + * @param {Object} params - Clone parameters + * @returns {Promise<{responseMessage: string}>} + */ + async cloneIdevice(params) { + throw new Error('ContentPort.cloneIdevice() not implemented'); + } + + /** + * Delete an iDevice. + * @param {string} ideviceId - iDevice ID to delete + * @returns {Promise<{responseMessage: string}>} + */ + async deleteIdevice(ideviceId) { + throw new Error('ContentPort.deleteIdevice() not implemented'); + } + + /** + * Generic send operation for custom endpoints. + * @param {string} endpointId - Endpoint identifier + * @param {Object} params - Request parameters + * @returns {Promise} + */ + async send(endpointId, params) { + throw new Error('ContentPort.send() not implemented'); + } +} + +export default ContentPort; diff --git a/public/app/core/ports/ExportPort.js b/public/app/core/ports/ExportPort.js new file mode 100644 index 000000000..c600668e2 --- /dev/null +++ b/public/app/core/ports/ExportPort.js @@ -0,0 +1,74 @@ +/** + * ExportPort - Domain interface for export operations. + * Implemented by ServerExportAdapter and StaticExportAdapter. + */ +export class ExportPort { + /** + * Export a project in the specified format. + * @param {string} format - Export format (html5, scorm12, scorm2004, epub3, etc.) + * @param {Object} projectData - Project data to export + * @param {Object} [options] - Export options + * @returns {Promise} - Exported content (Blob for download, string for URL) + */ + async exportAs(format, projectData, options = {}) { + throw new Error('ExportPort.exportAs() not implemented'); + } + + /** + * Get supported export formats. + * @returns {Promise>} + */ + async getSupportedFormats() { + throw new Error('ExportPort.getSupportedFormats() not implemented'); + } + + /** + * Check if a format is supported. + * @param {string} format - Format ID + * @returns {Promise} + */ + async isFormatSupported(format) { + throw new Error('ExportPort.isFormatSupported() not implemented'); + } + + /** + * Generate preview HTML for a project. + * @param {Object} projectData - Project data + * @returns {Promise} - HTML content or URL + */ + async generatePreview(projectData) { + throw new Error('ExportPort.generatePreview() not implemented'); + } + + /** + * Export project as ELPX package. + * @param {Object} projectData - Project data + * @param {Object} assets - Project assets + * @returns {Promise} + */ + async exportAsElpx(projectData, assets) { + throw new Error('ExportPort.exportAsElpx() not implemented'); + } + + /** + * Get preview URL for a session. + * @param {string} sessionId - Session ID + * @returns {Promise<{url: string}|{clientSidePreview: boolean}>} + */ + async getPreviewUrl(sessionId) { + throw new Error('ExportPort.getPreviewUrl() not implemented'); + } + + /** + * Download iDevice/block content as file. + * @param {string} sessionId - Session ID + * @param {string} blockId - Block ID + * @param {string} ideviceId - iDevice ID + * @returns {Promise<{url: string, response: string}>} + */ + async downloadIDevice(sessionId, blockId, ideviceId) { + throw new Error('ExportPort.downloadIDevice() not implemented'); + } +} + +export default ExportPort; diff --git a/public/app/core/ports/LinkValidationPort.js b/public/app/core/ports/LinkValidationPort.js new file mode 100644 index 000000000..5be8b1f20 --- /dev/null +++ b/public/app/core/ports/LinkValidationPort.js @@ -0,0 +1,72 @@ +/** + * LinkValidationPort - Domain interface for link validation operations. + * Handles broken link detection and validation across project content. + * Implemented by ServerLinkValidationAdapter and StaticLinkValidationAdapter. + */ +export class LinkValidationPort { + /** + * Get broken links for an entire session/project. + * @param {Object} params - Query parameters + * @param {string} params.odeSessionId - Session ID + * @returns {Promise<{responseMessage: string, brokenLinks: Array}>} + */ + async getSessionBrokenLinks(params) { + throw new Error('LinkValidationPort.getSessionBrokenLinks() not implemented'); + } + + /** + * Extract links from iDevices for validation. + * @param {Object} params - Extraction parameters + * @param {string} params.odeSessionId - Session ID + * @param {Array} params.idevices - iDevice data to extract links from + * @returns {Promise<{responseMessage: string, links: Array, totalLinks: number}>} + */ + async extractLinks(params) { + throw new Error('LinkValidationPort.extractLinks() not implemented'); + } + + /** + * Get the URL for the link validation stream endpoint (SSE). + * @returns {string} + */ + getValidationStreamUrl() { + throw new Error('LinkValidationPort.getValidationStreamUrl() not implemented'); + } + + /** + * Get broken links for a specific page. + * @param {string} pageId - Page ID + * @returns {Promise<{brokenLinks: Array}>} + */ + async getPageBrokenLinks(pageId) { + throw new Error('LinkValidationPort.getPageBrokenLinks() not implemented'); + } + + /** + * Get broken links for a specific block. + * @param {string} blockId - Block ID + * @returns {Promise<{brokenLinks: Array}>} + */ + async getBlockBrokenLinks(blockId) { + throw new Error('LinkValidationPort.getBlockBrokenLinks() not implemented'); + } + + /** + * Get broken links for a specific iDevice. + * @param {string} ideviceId - iDevice ID + * @returns {Promise<{brokenLinks: Array}>} + */ + async getIdeviceBrokenLinks(ideviceId) { + throw new Error('LinkValidationPort.getIdeviceBrokenLinks() not implemented'); + } + + /** + * Check if link validation is supported. + * @returns {boolean} + */ + isSupported() { + return true; + } +} + +export default LinkValidationPort; diff --git a/public/app/core/ports/PlatformIntegrationPort.js b/public/app/core/ports/PlatformIntegrationPort.js new file mode 100644 index 000000000..8eee441df --- /dev/null +++ b/public/app/core/ports/PlatformIntegrationPort.js @@ -0,0 +1,38 @@ +/** + * PlatformIntegrationPort - Domain interface for LMS platform integration. + * Handles integration with external Learning Management Systems (Moodle, etc.). + * Implemented by ServerPlatformIntegrationAdapter and StaticPlatformIntegrationAdapter. + */ +export class PlatformIntegrationPort { + /** + * Upload a new ELP file to an LMS platform. + * @param {Object} params - Upload parameters + * @param {string} params.platformId - Target platform ID + * @param {string} params.projectId - Project ID to upload + * @returns {Promise<{responseMessage: string}>} + */ + async uploadElp(params) { + throw new Error('PlatformIntegrationPort.uploadElp() not implemented'); + } + + /** + * Open an ELP file from an LMS platform. + * @param {Object} params - Open parameters + * @param {string} params.platformId - Source platform ID + * @param {string} params.fileId - File ID on the platform + * @returns {Promise<{responseMessage: string}>} + */ + async openElp(params) { + throw new Error('PlatformIntegrationPort.openElp() not implemented'); + } + + /** + * Check if platform integration is supported. + * @returns {boolean} + */ + isSupported() { + return true; + } +} + +export default PlatformIntegrationPort; diff --git a/public/app/core/ports/ProjectRepositoryPort.js b/public/app/core/ports/ProjectRepositoryPort.js new file mode 100644 index 000000000..43a0ac7e5 --- /dev/null +++ b/public/app/core/ports/ProjectRepositoryPort.js @@ -0,0 +1,294 @@ +/** + * ProjectRepositoryPort - Domain interface for project persistence. + * Implemented by ServerProjectRepository and StaticProjectRepository. + */ +export class ProjectRepositoryPort { + /** + * List all projects for the current user. + * @returns {Promise>} + */ + async list() { + throw new Error('ProjectRepositoryPort.list() not implemented'); + } + + /** + * Get a project by ID. + * @param {string} id - Project ID or UUID + * @returns {Promise} + */ + async get(id) { + throw new Error('ProjectRepositoryPort.get() not implemented'); + } + + /** + * Create a new project. + * @param {Object} data - Project data + * @param {string} data.title - Project title + * @returns {Promise<{id: string, uuid: string}>} + */ + async create(data) { + throw new Error('ProjectRepositoryPort.create() not implemented'); + } + + /** + * Update an existing project. + * @param {string} id - Project ID or UUID + * @param {Object} data - Updated project data + * @returns {Promise} + */ + async update(id, data) { + throw new Error('ProjectRepositoryPort.update() not implemented'); + } + + /** + * Delete a project. + * @param {string} id - Project ID or UUID + * @returns {Promise} + */ + async delete(id) { + throw new Error('ProjectRepositoryPort.delete() not implemented'); + } + + /** + * Get recent projects. + * @param {number} limit - Maximum number of projects to return + * @returns {Promise} + */ + async getRecent(limit = 10) { + throw new Error('ProjectRepositoryPort.getRecent() not implemented'); + } + + /** + * Check if a project exists. + * @param {string} id - Project ID or UUID + * @returns {Promise} + */ + async exists(id) { + throw new Error('ProjectRepositoryPort.exists() not implemented'); + } + + /** + * Save a project (manual save). + * @param {string} sessionId - Session ID + * @param {Object} params - Save parameters + * @returns {Promise<{responseMessage: string}>} + */ + async save(sessionId, params) { + throw new Error('ProjectRepositoryPort.save() not implemented'); + } + + /** + * Autosave a project. + * @param {string} sessionId - Session ID + * @param {Object} params - Autosave parameters + * @returns {Promise} + */ + async autoSave(sessionId, params) { + throw new Error('ProjectRepositoryPort.autoSave() not implemented'); + } + + /** + * Save project as new copy. + * @param {string} sessionId - Session ID + * @param {Object} params - SaveAs parameters + * @returns {Promise<{responseMessage: string, newProjectId?: string}>} + */ + async saveAs(sessionId, params) { + throw new Error('ProjectRepositoryPort.saveAs() not implemented'); + } + + /** + * Duplicate a project. + * @param {string} id - Project ID to duplicate + * @returns {Promise<{id: string, uuid: string}>} + */ + async duplicate(id) { + throw new Error('ProjectRepositoryPort.duplicate() not implemented'); + } + + /** + * Get project last updated timestamp. + * @param {string} id - Project ID + * @returns {Promise<{lastUpdated: string}>} + */ + async getLastUpdated(id) { + throw new Error('ProjectRepositoryPort.getLastUpdated() not implemented'); + } + + /** + * Get concurrent users for a project. + * @param {string} id - Project ID + * @param {string} versionId - Version ID + * @param {string} sessionId - Session ID + * @returns {Promise<{users: Array}>} + */ + async getConcurrentUsers(id, versionId, sessionId) { + throw new Error('ProjectRepositoryPort.getConcurrentUsers() not implemented'); + } + + /** + * Close a project session. + * @param {Object} params - Close session parameters + * @returns {Promise<{responseMessage: string}>} + */ + async closeSession(params) { + throw new Error('ProjectRepositoryPort.closeSession() not implemented'); + } + + /** + * Join a project session. + * @param {string} sessionId - Session ID to join + * @returns {Promise<{available: boolean}>} + */ + async joinSession(sessionId) { + throw new Error('ProjectRepositoryPort.joinSession() not implemented'); + } + + /** + * Check current users in a session. + * @param {Object} params - Check parameters + * @returns {Promise<{currentUsers: number}>} + */ + async checkCurrentUsers(params) { + throw new Error('ProjectRepositoryPort.checkCurrentUsers() not implemented'); + } + + /** + * Open/select a file for editing. + * @param {string} fileName - File name or path + * @returns {Promise<{responseMessage: string, odeSessionId: string}>} + */ + async openFile(fileName) { + throw new Error('ProjectRepositoryPort.openFile() not implemented'); + } + + /** + * Open a local file (from browser upload). + * @param {Object} data - File data + * @returns {Promise<{responseMessage: string, odeSessionId: string}>} + */ + async openLocalFile(data) { + throw new Error('ProjectRepositoryPort.openLocalFile() not implemented'); + } + + /** + * Open a large local file (chunked upload). + * @param {Object} data - File data + * @returns {Promise<{responseMessage: string, odeSessionId: string}>} + */ + async openLargeLocalFile(data) { + throw new Error('ProjectRepositoryPort.openLargeLocalFile() not implemented'); + } + + /** + * Get properties from local XML file. + * @param {Object} data - File data + * @returns {Promise<{responseMessage: string, properties: Object}>} + */ + async getLocalProperties(data) { + throw new Error('ProjectRepositoryPort.getLocalProperties() not implemented'); + } + + /** + * Get components from local file. + * @param {Object} data - File data + * @returns {Promise<{responseMessage: string, components: Array}>} + */ + async getLocalComponents(data) { + throw new Error('ProjectRepositoryPort.getLocalComponents() not implemented'); + } + + /** + * Import ELP file to root. + * @param {Object} data - Import data + * @returns {Promise<{responseMessage: string}>} + */ + async importToRoot(data) { + throw new Error('ProjectRepositoryPort.importToRoot() not implemented'); + } + + /** + * Import ELP file from local path to root. + * @param {Object} payload - Import payload + * @returns {Promise<{responseMessage: string}>} + */ + async importToRootFromLocal(payload) { + throw new Error('ProjectRepositoryPort.importToRootFromLocal() not implemented'); + } + + /** + * Import ELP file as child of a navigation node. + * @param {string} navId - Navigation node ID + * @param {Object} payload - Import payload + * @returns {Promise<{responseMessage: string}>} + */ + async importAsChild(navId, payload) { + throw new Error('ProjectRepositoryPort.importAsChild() not implemented'); + } + + /** + * Open multiple local files. + * @param {Object} data - Files data + * @returns {Promise<{responseMessage: string}>} + */ + async openMultipleLocalFiles(data) { + throw new Error('ProjectRepositoryPort.openMultipleLocalFiles() not implemented'); + } + + /** + * Delete old files by date. + * @param {Object} params - Delete parameters (date cutoff) + * @returns {Promise<{responseMessage: string}>} + */ + async deleteByDate(params) { + throw new Error('ProjectRepositoryPort.deleteByDate() not implemented'); + } + + /** + * Clean autosaves for user. + * @param {Object} params - Clean parameters + * @returns {Promise<{responseMessage: string}>} + */ + async cleanAutosaves(params) { + throw new Error('ProjectRepositoryPort.cleanAutosaves() not implemented'); + } + + /** + * Get project structure from session. + * @param {string} versionId - Version ID + * @param {string} sessionId - Session ID + * @returns {Promise<{structure: Object|null}>} + */ + async getStructure(versionId, sessionId) { + throw new Error('ProjectRepositoryPort.getStructure() not implemented'); + } + + /** + * Get project properties. + * @param {string} sessionId - Session ID + * @returns {Promise<{responseMessage: string, properties: Object}>} + */ + async getProperties(sessionId) { + throw new Error('ProjectRepositoryPort.getProperties() not implemented'); + } + + /** + * Save project properties. + * @param {Object} params - Properties to save + * @returns {Promise<{responseMessage: string}>} + */ + async saveProperties(params) { + throw new Error('ProjectRepositoryPort.saveProperties() not implemented'); + } + + /** + * Get used files in session. + * @param {Object} params - Query parameters + * @returns {Promise<{responseMessage: string, usedFiles: Array}>} + */ + async getUsedFiles(params) { + throw new Error('ProjectRepositoryPort.getUsedFiles() not implemented'); + } +} + +export default ProjectRepositoryPort; diff --git a/public/app/core/ports/SharingPort.js b/public/app/core/ports/SharingPort.js new file mode 100644 index 000000000..5117f7135 --- /dev/null +++ b/public/app/core/ports/SharingPort.js @@ -0,0 +1,66 @@ +/** + * SharingPort - Domain interface for project sharing operations. + * Handles project visibility, collaborators, and ownership transfer. + * Implemented by ServerSharingAdapter and StaticSharingAdapter. + */ +export class SharingPort { + /** + * Get project sharing information. + * @param {string|number} projectId - Project ID or UUID + * @returns {Promise<{responseMessage: string, project?: Object}>} + */ + async getProject(projectId) { + throw new Error('SharingPort.getProject() not implemented'); + } + + /** + * Update project visibility (public/private). + * @param {string|number} projectId - Project ID or UUID + * @param {string} visibility - 'public' or 'private' + * @returns {Promise<{responseMessage: string, project?: Object}>} + */ + async updateVisibility(projectId, visibility) { + throw new Error('SharingPort.updateVisibility() not implemented'); + } + + /** + * Add a collaborator to a project. + * @param {string|number} projectId - Project ID or UUID + * @param {string} email - Collaborator's email + * @param {string} [role='editor'] - Role (editor, viewer) + * @returns {Promise<{responseMessage: string}>} + */ + async addCollaborator(projectId, email, role = 'editor') { + throw new Error('SharingPort.addCollaborator() not implemented'); + } + + /** + * Remove a collaborator from a project. + * @param {string|number} projectId - Project ID or UUID + * @param {number} userId - Collaborator's user ID + * @returns {Promise<{responseMessage: string}>} + */ + async removeCollaborator(projectId, userId) { + throw new Error('SharingPort.removeCollaborator() not implemented'); + } + + /** + * Transfer project ownership to another user. + * @param {string|number} projectId - Project ID or UUID + * @param {number} newOwnerId - New owner's user ID + * @returns {Promise<{responseMessage: string, project?: Object}>} + */ + async transferOwnership(projectId, newOwnerId) { + throw new Error('SharingPort.transferOwnership() not implemented'); + } + + /** + * Check if sharing is supported in current mode. + * @returns {boolean} + */ + isSupported() { + return true; + } +} + +export default SharingPort; diff --git a/public/app/core/ports/UserPreferencePort.js b/public/app/core/ports/UserPreferencePort.js new file mode 100644 index 000000000..efe2290ea --- /dev/null +++ b/public/app/core/ports/UserPreferencePort.js @@ -0,0 +1,60 @@ +/** + * UserPreferencePort - Domain interface for user preference operations. + * Implemented by ServerUserPreferenceAdapter and StaticUserPreferenceAdapter. + */ +export class UserPreferencePort { + /** + * Get user preferences. + * @returns {Promise<{userPreferences: Object}>} + */ + async getPreferences() { + throw new Error('UserPreferencePort.getPreferences() not implemented'); + } + + /** + * Save user preferences. + * @param {Object} params - Preferences to save + * @returns {Promise<{success: boolean}>} + */ + async savePreferences(params) { + throw new Error('UserPreferencePort.savePreferences() not implemented'); + } + + /** + * Accept LOPD (data protection). + * @returns {Promise<{success: boolean}>} + */ + async acceptLopd() { + throw new Error('UserPreferencePort.acceptLopd() not implemented'); + } + + /** + * Check if LOPD has been accepted. + * @returns {Promise} + */ + async isLopdAccepted() { + throw new Error('UserPreferencePort.isLopdAccepted() not implemented'); + } + + /** + * Get a specific preference value. + * @param {string} key - Preference key + * @param {*} defaultValue - Default value if not found + * @returns {Promise<*>} + */ + async getPreference(key, defaultValue = null) { + throw new Error('UserPreferencePort.getPreference() not implemented'); + } + + /** + * Set a specific preference value. + * @param {string} key - Preference key + * @param {*} value - Preference value + * @returns {Promise<{success: boolean}>} + */ + async setPreference(key, value) { + throw new Error('UserPreferencePort.setPreference() not implemented'); + } +} + +export default UserPreferencePort; diff --git a/public/app/core/ports/index.js b/public/app/core/ports/index.js new file mode 100644 index 000000000..80100fedc --- /dev/null +++ b/public/app/core/ports/index.js @@ -0,0 +1,14 @@ +/** + * Port interfaces - Domain contracts for dependency injection. + * These are the abstract interfaces that adapters implement. + */ +export { ProjectRepositoryPort } from './ProjectRepositoryPort.js'; +export { CatalogPort } from './CatalogPort.js'; +export { AssetPort } from './AssetPort.js'; +export { CollaborationPort } from './CollaborationPort.js'; +export { ExportPort } from './ExportPort.js'; +export { LinkValidationPort } from './LinkValidationPort.js'; +export { CloudStoragePort } from './CloudStoragePort.js'; +export { PlatformIntegrationPort } from './PlatformIntegrationPort.js'; +export { SharingPort } from './SharingPort.js'; +export { ContentPort } from './ContentPort.js'; diff --git a/public/app/editor/tinymce_5_extra.css b/public/app/editor/tinymce_5_extra.css index 04248e8b8..f6448d1ee 100644 --- a/public/app/editor/tinymce_5_extra.css +++ b/public/app/editor/tinymce_5_extra.css @@ -1,5 +1,5 @@ /* Custom CSS here */ -@import url("/app/common/exe_wikipedia/exe_wikipedia.css"); +@import url("../common/exe_wikipedia/exe_wikipedia.css"); /* Form iDevice (selected words) */ body#tinymce{padding:1em} #tinymce u{font-style:italic;text-decoration:none;opacity:.5} diff --git a/public/app/locate/locale.js b/public/app/locate/locale.js index 983b495e7..63b89639a 100644 --- a/public/app/locate/locale.js +++ b/public/app/locate/locale.js @@ -45,6 +45,119 @@ export default class Locale { */ async loadTranslationsStrings() { this.strings = await this.app.api.getTranslations(this.lang); + // Re-translate static UI elements (menus, modals, buttons) + this.translateStaticUI(); + } + + /** + * Translate static UI elements that were baked into HTML at build time. + * This is needed for static mode where the HTML is pre-generated. + */ + translateStaticUI() { + // Map of element selectors to their translation keys + const translations = { + // Main menu items + '#dropdownFile': 'File', + '#dropdownUtilities': 'Utilities', + '#dropdownHelp': 'Help', + // File menu + '#navbar-button-new': 'New', + '#navbar-button-new-from-template': 'New from Template...', + '#navbar-button-openuserodefiles': 'Open', + '#navbar-button-dropdown-recent-projects': 'Recent projects', + '#navbar-button-import-elp': 'Import (.elpx…)', + '#navbar-button-save': 'Save', + '#navbar-button-save-as': 'Save as', + '#dropdownExportAs': 'Download as...', + '#navbar-button-download-project': 'eXeLearning content (.elpx)', + '#navbar-button-export-html5': 'Website', + '#navbar-button-export-html5-sp': 'Single page', + '#navbar-button-settings': 'Settings', + '#navbar-button-share': 'Share', + '#navbar-button-open-offline': 'Open', + '#navbar-button-save-offline': 'Save', + '#navbar-button-save-as-offline': 'Save as', + '#dropdownExportAsOffline': 'Export as...', + '#navbar-button-exportas-html5': 'Website', + '#navbar-button-exportas-html5-folder': 'Export to Folder (Unzipped Website)', + '#navbar-button-exportas-html5-sp': 'Single page', + '#navbar-button-export-print': 'Print', + '#dropdownUploadTo': 'Upload to', + '#dropdownProperties': 'Metadata', + '#navbar-button-import-xml-properties': 'Import', + '#navbar-button-export-xml-properties': 'Export', + // Utilities menu + '#navbar-button-idevice-manager': 'iDevice manager', + '#navbar-button-odeusedfiles': 'Resources report', + '#navbar-button-odebrokenlinks': 'Link validation', + '#navbar-button-filemanager': 'File manager', + '#navbar-button-styles': 'Styles', + '#navbar-button-preview': 'Preview', + '#navbar-button-preferences': 'Preferences', + // Help menu + '#navbar-button-assistant': 'Assistant', + '#navbar-button-exe-tutorial': 'User manual', + '#navbar-button-api-docs': 'API Reference (Swagger)', + '#navbar-button-about-exe': 'About eXeLearning', + '#navbar-button-release-notes': 'Release notes', + '#navbar-button-legal-notes': 'Legal notes', + '#navbar-button-exe-web': 'eXeLearning website', + '#navbar-button-report-bug': 'Report a bug', + // Head buttons - only translate text inside span, not the whole button + '#head-top-save-button > span:not([class*="icon"])': 'Save', + }; + + for (const [selector, key] of Object.entries(translations)) { + const elements = document.querySelectorAll(selector); + elements.forEach(el => { + // Get translated text + const translated = _(key); + // For menu items, preserve icons (spans with icon classes) + const iconSpan = el.querySelector('span[class*="icon"], div[class*="icon"]'); + if (iconSpan) { + // Clear all text nodes first, then add translated text after the icon + const textNodes = Array.from(el.childNodes).filter(n => n.nodeType === Node.TEXT_NODE); + textNodes.forEach(n => n.remove()); + // Add text after the icon + if (iconSpan.nextSibling && iconSpan.nextSibling.nodeType === Node.TEXT_NODE) { + iconSpan.nextSibling.textContent = ' ' + translated; + } else { + iconSpan.after(document.createTextNode(' ' + translated)); + } + } else if (el.tagName === 'A' || el.tagName === 'BUTTON') { + // For links/buttons without icons, check for keyboard shortcuts + const shortcut = el.querySelector('.shortcut, kbd'); + if (shortcut) { + const firstTextNode = Array.from(el.childNodes).find(n => n.nodeType === Node.TEXT_NODE); + if (firstTextNode) { + firstTextNode.textContent = translated; + } + } else { + // Simple text replacement, but preserve any child elements + const firstTextNode = Array.from(el.childNodes).find(n => n.nodeType === Node.TEXT_NODE); + if (firstTextNode) { + firstTextNode.textContent = translated; + } + } + } else { + // For other elements (like spans), just set text content + el.textContent = translated; + } + }); + } + + // Translate modal titles and common elements + const modalTitles = { + '#modalProperties .modal-title': 'Preferences', + '#modalAbout .modal-title': 'About eXeLearning', + '#modalReleaseNotes .modal-title': 'Release notes', + '#modalLegalNotes .modal-title': 'Legal notes', + }; + + for (const [selector, key] of Object.entries(modalTitles)) { + const el = document.querySelector(selector); + if (el) el.textContent = _(key); + } } getGUITranslation(string) { diff --git a/public/app/locate/locale.test.js b/public/app/locate/locale.test.js index 98f78e641..18da10ac9 100644 --- a/public/app/locate/locale.test.js +++ b/public/app/locate/locale.test.js @@ -47,11 +47,219 @@ describe('Locale translations', () => { expect(document.querySelector('body').getAttribute('lang')).toBe('fr'); }); - it('loadTranslationsStrings populates strings via API', async () => { + it('loadTranslationsStrings populates strings via API and calls translateStaticUI', async () => { + // Spy on translateStaticUI + const translateSpy = vi.spyOn(locale, 'translateStaticUI').mockImplementation(() => {}); + await locale.setLocaleLang('es'); await locale.loadTranslationsStrings(); + expect(mockApp.api.getTranslations).toHaveBeenCalledWith('es'); expect(locale.strings.translations.hello).toBe('~Hola'); + expect(translateSpy).toHaveBeenCalled(); + }); + + it('translateStaticUI translates menu elements using _() function', () => { + // Set up translations + locale.strings = { + translations: { + File: 'Archivo', + Utilities: 'Utilidades', + }, + }; + + // Create mock DOM elements + document.body.innerHTML = ` + File + Utilities + `; + + locale.translateStaticUI(); + + expect(document.querySelector('#dropdownFile').textContent).toBe('Archivo'); + expect(document.querySelector('#dropdownUtilities').textContent).toBe('Utilidades'); + }); + + describe('translateStaticUI - icon handling', () => { + beforeEach(() => { + locale.strings = { + translations: { + 'Recent projects': 'Proyectos recientes', + 'New': 'Nuevo', + 'Save': 'Guardar', + 'Open': 'Abrir', + 'Preview': 'Vista previa', + }, + }; + }); + + it('should translate element with span icon and place text after icon', () => { + document.body.innerHTML = ` + + Recent projects + + `; + + locale.translateStaticUI(); + + const el = document.querySelector('#navbar-button-dropdown-recent-projects'); + expect(el.textContent.trim()).toBe('Proyectos recientes'); + // Icon should still be there + expect(el.querySelector('span.small-icon')).not.toBeNull(); + }); + + it('should translate element with div icon', () => { + document.body.innerHTML = ` + + `; + + locale.translateStaticUI(); + + const el = document.querySelector('#navbar-button-new'); + expect(el.textContent.trim()).toBe('Nuevo'); + expect(el.querySelector('div.small-icon')).not.toBeNull(); + }); + + it('should remove duplicate text nodes and add only one after icon', () => { + // Simulate the bug case where text appears both before and after icon + document.body.innerHTML = ` + + Recent projects Recent projects + + `; + + locale.translateStaticUI(); + + const el = document.querySelector('#navbar-button-dropdown-recent-projects'); + // Should only have the icon and one text node after it + expect(el.textContent.trim()).toBe('Proyectos recientes'); + // Count text content occurrences + const textContent = el.textContent; + const count = (textContent.match(/Proyectos recientes/g) || []).length; + expect(count).toBe(1); + }); + + it('should handle element with icon but no existing text nodes', () => { + document.body.innerHTML = ` + + + + `; + + locale.translateStaticUI(); + + const el = document.querySelector('#navbar-button-dropdown-recent-projects'); + expect(el.textContent).toContain('Proyectos recientes'); + }); + }); + + describe('translateStaticUI - button/link handling', () => { + beforeEach(() => { + locale.strings = { + translations: { + 'Save': 'Guardar', + 'Open': 'Abrir', + }, + }; + }); + + it('should translate link without icon', () => { + document.body.innerHTML = ` + Save + `; + + locale.translateStaticUI(); + + expect(document.querySelector('#navbar-button-save').textContent).toBe('Guardar'); + }); + + it('should translate button without icon', () => { + document.body.innerHTML = ` + + `; + + locale.translateStaticUI(); + + expect(document.querySelector('#navbar-button-save').textContent).toBe('Guardar'); + }); + + it('should preserve child elements when translating', () => { + document.body.innerHTML = ` + SaveCtrl+S + `; + + locale.translateStaticUI(); + + const el = document.querySelector('#navbar-button-save'); + expect(el.querySelector('kbd')).not.toBeNull(); + expect(el.querySelector('kbd').textContent).toBe('Ctrl+S'); + }); + }); + + describe('translateStaticUI - span elements', () => { + beforeEach(() => { + locale.strings = { + translations: { + 'Save': 'Guardar', + }, + }; + }); + + it('should translate span element inside save button', () => { + document.body.innerHTML = ` + + `; + + locale.translateStaticUI(); + + const span = document.querySelector('#head-top-save-button > span:not([class*="icon"])'); + expect(span.textContent).toBe('Guardar'); + }); + }); + + describe('translateStaticUI - modal titles', () => { + beforeEach(() => { + locale.strings = { + translations: { + 'Preferences': 'Preferencias', + 'About eXeLearning': 'Acerca de eXeLearning', + }, + }; + }); + + it('should translate modal titles', () => { + document.body.innerHTML = ` +
+ +
+
+ +
+ `; + + locale.translateStaticUI(); + + expect(document.querySelector('#modalProperties .modal-title').textContent).toBe('Preferencias'); + expect(document.querySelector('#modalAbout .modal-title').textContent).toBe('Acerca de eXeLearning'); + }); + }); + + describe('translateStaticUI - no matching elements', () => { + it('should not throw when elements do not exist', () => { + locale.strings = { + translations: { + 'File': 'Archivo', + }, + }; + document.body.innerHTML = '
Empty
'; + + expect(() => locale.translateStaticUI()).not.toThrow(); + }); }); it('getGUITranslation returns cleaned translation with tilde removed', () => { @@ -109,4 +317,54 @@ describe('Locale translations', () => { it('getTranslation returns empty for non-string inputs', () => { expect(locale.getTranslation(123)).toBe(''); }); + + describe('init', () => { + it('should call setLocaleLang and loadTranslationsStrings', async () => { + const setLocaleLangSpy = vi.spyOn(locale, 'setLocaleLang').mockImplementation(() => {}); + const loadTranslationsSpy = vi.spyOn(locale, 'loadTranslationsStrings').mockResolvedValue(); + + await locale.init(); + + expect(setLocaleLangSpy).toHaveBeenCalledWith('es'); + expect(loadTranslationsSpy).toHaveBeenCalled(); + }); + }); + + describe('getGUITranslation edge cases', () => { + it('should return original string with escaped quotes removed when key not found', () => { + locale.strings = { translations: {} }; + expect(locale.getGUITranslation('unknown key')).toBe('unknown key'); + }); + + it('should handle string with quotes when key not found', () => { + locale.strings = { translations: {} }; + expect(locale.getGUITranslation('text "quoted"')).toBe('text "quoted"'); + }); + }); + + describe('translateStaticUI - icon with existing text sibling', () => { + beforeEach(() => { + locale.strings = { + translations: { + 'Save': 'Guardar', + }, + }; + }); + + it('should update existing text node after icon instead of creating new one', () => { + // Create element with icon followed by text node + document.body.innerHTML = ` + Save + `; + + locale.translateStaticUI(); + + const el = document.querySelector('#navbar-button-save'); + // Should have updated the text node, not created a new one + expect(el.textContent.trim()).toBe('Guardar'); + // Count child nodes - should be icon + one text node + const textNodes = Array.from(el.childNodes).filter(n => n.nodeType === Node.TEXT_NODE); + expect(textNodes.length).toBe(1); + }); + }); }); diff --git a/public/app/rest/apiCallManager.js b/public/app/rest/apiCallManager.js index 211a78147..eec5e9668 100644 --- a/public/app/rest/apiCallManager.js +++ b/public/app/rest/apiCallManager.js @@ -1,22 +1,101 @@ import ApiCallBaseFunctions from './apiCallBaseFunctions.js'; export default class ApiCallManager { - constructor(app) { + /** + * @param {Object} app - App instance + * @param {Object} [options] - Optional adapters for dependency injection + * @param {Object} [options.projectRepo] - Project repository adapter + * @param {Object} [options.catalog] - Catalog adapter + * @param {Object} [options.assets] - Asset adapter + * @param {Object} [options.collaboration] - Collaboration adapter + * @param {Object} [options.exportAdapter] - Export adapter + * @param {Object} [options.userPreferences] - User preferences adapter + * @param {Object} [options.linkValidation] - Link validation adapter + * @param {Object} [options.cloudStorage] - Cloud storage adapter + * @param {Object} [options.platformIntegration] - Platform integration adapter + * @param {Object} [options.sharing] - Sharing adapter + * @param {Object} [options.content] - Content adapter for page/block operations + */ + constructor(app, options = {}) { this.app = app; this.apiUrlBase = `${app.eXeLearning.config.baseURL}`; this.apiUrlBasePath = `${app.eXeLearning.config.basePath}`; this.apiUrlParameters = `${this.apiUrlBase}${this.apiUrlBasePath}/api/parameter-management/parameters/data/list`; this.func = new ApiCallBaseFunctions(); this.endpoints = {}; + + // Injected adapters (optional, for gradual migration) + // When adapters are provided, methods will use them instead of conditionals + this._projectRepo = options.projectRepo || null; + this._catalog = options.catalog || null; + this._assets = options.assets || null; + this._collaboration = options.collaboration || null; + this._exportAdapter = options.exportAdapter || null; + this._userPreferences = options.userPreferences || null; + this._linkValidation = options.linkValidation || null; + this._cloudStorage = options.cloudStorage || null; + this._platformIntegration = options.platformIntegration || null; + this._sharing = options.sharing || null; + this._content = options.content || null; + } + + /** + * Check if an adapter is available for use. + * @param {string} adapterName - Name of the adapter + * @returns {boolean} + */ + _hasAdapter(adapterName) { + return this[`_${adapterName}`] !== null; + } + + /** + * Inject adapters after construction. + * This allows for async adapter creation during app initialization. + * @param {Object} adapters - Object containing adapter instances + */ + setAdapters(adapters) { + if (adapters.projectRepo) this._projectRepo = adapters.projectRepo; + if (adapters.catalog) this._catalog = adapters.catalog; + if (adapters.assets) this._assets = adapters.assets; + if (adapters.collaboration) this._collaboration = adapters.collaboration; + if (adapters.exportAdapter) this._exportAdapter = adapters.exportAdapter; + if (adapters.userPreferences) this._userPreferences = adapters.userPreferences; + if (adapters.linkValidation) this._linkValidation = adapters.linkValidation; + if (adapters.cloudStorage) this._cloudStorage = adapters.cloudStorage; + if (adapters.platformIntegration) this._platformIntegration = adapters.platformIntegration; + if (adapters.sharing) this._sharing = adapters.sharing; + if (adapters.content) this._content = adapters.content; + } + + /** + * Safely get endpoint URL + * Returns null if endpoint doesn't exist (common in static mode) + * @param {string} endpointName - Name of the endpoint + * @returns {string|null} - URL or null if not available + */ + _getEndpointUrl(endpointName) { + const endpoint = this.endpoints[endpointName]; + if (!endpoint || !endpoint.path) { + // Silently return null if no remote storage (static/offline mode) + const capabilities = this.app.capabilities; + if (capabilities && !capabilities.storage.remote) { + return null; + } + console.warn( + `[apiCallManager] Endpoint not found: ${endpointName}` + ); + return null; + } + return endpoint.path; } /** * Load symfony api endpoints routes - * + * In static mode, loads from DataProvider instead of server */ async loadApiParameters() { this.parameters = await this.getApiParameters(); - for (var [key, data] of Object.entries(this.parameters.routes)) { + for (var [key, data] of Object.entries(this.parameters.routes || {})) { this.endpoints[key] = {}; this.endpoints[key].path = this.apiUrlBase + data.path; this.endpoints[key].methods = data.methods; @@ -25,155 +104,138 @@ export default class ApiCallManager { /** * Get symfony api endpoints parameters + * Uses injected catalog adapter (server or static mode) * * @returns */ async getApiParameters() { - let url = this.apiUrlParameters; - return await this.func.get(url); + return this._catalog.getApiParameters(); } /** * Get app changelog text + * Uses injected catalog adapter (server or static mode) * * @returns */ async getChangelogText() { - let url = this.app.eXeLearning.config.changelogURL; - url += '?version=' + eXeLearning.app.common.getVersionTimeStamp(); - return await this.func.getText(url); + return this._catalog.getChangelog(); } /** * Get upload limits configuration - * - * Returns the effective file upload size limit considering both - * PHP limits and application configuration. + * Uses injected catalog adapter (server or static mode) * * @returns {Promise<{maxFileSize: number, maxFileSizeFormatted: string, limitingFactor: string, details: object}>} */ async getUploadLimits() { - const url = `${this.apiUrlBase}${this.apiUrlBasePath}/api/config/upload-limits`; - return await this.func.get(url); + return this._catalog.getUploadLimits(); } /** * Get the third party code information + * Uses injected catalog adapter (server or static mode) * * @returns */ async getThirdPartyCodeText() { - // Use basePath + version for proper cache busting - // URL pattern: {basePath}/{version}/path (e.g., /web/exelearning/v0.0.0-alpha/libs/README) - const version = eXeLearning?.version || 'v1.0.0'; - let url = this.apiUrlBase + this.apiUrlBasePath + '/' + version + '/libs/README'; - return await this.func.getText(url); + return this._catalog.getThirdPartyCode(); } /** * Get the list of licenses + * Uses injected catalog adapter (server or static mode) * * @returns */ async getLicensesList() { - // Use basePath + version for proper cache busting - // URL pattern: {basePath}/{version}/path (e.g., /web/exelearning/v0.0.0-alpha/libs/LICENSES) - const version = eXeLearning?.version || 'v1.0.0'; - let url = this.apiUrlBase + this.apiUrlBasePath + '/' + version + '/libs/LICENSES'; - return await this.func.getText(url); + return this._catalog.getLicensesList(); } /** * Get idevices installed + * Uses injected catalog adapter (server or static mode) * * @returns */ async getIdevicesInstalled() { - let url = this.endpoints.api_idevices_installed.path; - return await this.func.get(url); + return this._catalog.getIDevices(); } /** * Get themes installed + * Uses injected catalog adapter (server or static mode) * * @returns */ async getThemesInstalled() { - let url = this.endpoints.api_themes_installed.path; - return await this.func.get(url); + return this._catalog.getThemes(); } /** * Get user odefiles (projects) - * Uses NestJS endpoint for Yjs-based projects + * Uses injected project repository (server or static mode) * * @returns {Promise} Response with odeFiles containing odeFilesSync array */ async getUserOdeFiles() { - // Use NestJS endpoint for Yjs projects - const url = `${this.apiUrlBase}${this.apiUrlBasePath}/api/projects/user/list`; - - // Get auth token from available sources - const authToken = eXeLearning?.app?.project?._yjsBridge?.authToken || - eXeLearning?.app?.auth?.getToken?.() || - eXeLearning?.config?.token || - localStorage.getItem('authToken'); - try { - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {}), - }, - credentials: 'include', - }); + const projects = await this._projectRepo.list(); + return { odeFiles: { odeFilesSync: projects } }; + } catch (error) { + console.error('[API] getUserOdeFiles error:', error); + return { odeFiles: { odeFilesSync: [] } }; + } + } - if (!response.ok) { - console.error('[API] getUserOdeFiles failed:', response.status); + /** + * Get local projects from IndexedDB (for static mode) + * Scans IndexedDB for exelearning-project-* databases + * @private + */ + async _getLocalProjects() { + try { + // Get list of IndexedDB databases (if supported) + if (!window.indexedDB?.databases) { + console.log('[API] indexedDB.databases() not supported, returning empty list'); return { odeFiles: { odeFilesSync: [] } }; } - return await response.json(); + const databases = await window.indexedDB.databases(); + const projectDatabases = databases.filter( + db => db.name?.startsWith('exelearning-project-') + ); + + const projects = projectDatabases.map(db => { + const uuid = db.name.replace('exelearning-project-', ''); + return { + uuid: uuid, + title: `Local Project (${uuid.substring(0, 8)}...)`, + updatedAt: new Date().toISOString(), + isLocal: true, + }; + }); + + return { + odeFiles: { + odeFilesSync: projects, + }, + }; } catch (error) { - console.error('[API] getUserOdeFiles error:', error); + console.error('[API] _getLocalProjects error:', error); return { odeFiles: { odeFilesSync: [] } }; } } /** * Get recent user odefiles (projects) - * Uses NestJS endpoint for Yjs-based projects - * Returns the 3 most recently updated projects + * Uses injected project repository (server or static mode) * * @returns {Promise} Array of recent project objects */ async getRecentUserOdeFiles() { - const url = `${this.apiUrlBase}${this.apiUrlBasePath}/api/projects/user/recent`; - - // Get auth token from available sources - const authToken = - eXeLearning?.app?.project?._yjsBridge?.authToken || - eXeLearning?.app?.auth?.getToken?.() || - eXeLearning?.config?.token || - localStorage.getItem('authToken'); - try { - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), - }, - credentials: 'include', - }); - - if (!response.ok) { - console.error('[API] getRecentUserOdeFiles failed:', response.status); - return []; - } - - return await response.json(); + return await this._projectRepo.getRecent(); } catch (error) { console.error('[API] getRecentUserOdeFiles error:', error); return []; @@ -202,132 +264,117 @@ export default class ApiCallManager { /** * Get available templates for a given locale + * Uses injected catalog adapter (server or static mode) * * @param {string} locale - The locale code (e.g., 'en', 'es') * @returns {Promise} - Array of template objects */ async getTemplates(locale) { - let url = `${this.apiUrlBase}${this.apiUrlBasePath}/api/templates?locale=${locale}`; - return await this.func.get(url); + return this._catalog.getTemplates(locale); } /** * Post odeSessionId and check availability + * Uses injected project repository (server or static mode) * * @param {*} params * @returns */ async postJoinCurrentOdeSessionId(params) { - let url = this.endpoints.check_current_users_ode_session_id.path; - return await this.func.post(url, params); + const result = await this._projectRepo.joinSession(params.odeSessionId); + return { responseMessage: 'OK', ...result }; } /** * Post selected odefile + * Uses injected project repository (server or static mode) * * @param {*} odeFileName * @returns */ async postSelectedOdeFile(odeFileName) { - let url = this.endpoints.api_odes_ode_elp_open.path; - return await this.func.post(url, odeFileName); + return this._projectRepo.openFile(odeFileName); } /** - * + * Open large local ODE file + * Uses injected project repository (server or static mode) * @param {*} data * @returns */ async postLocalLargeOdeFile(data) { - let url = this.endpoints.api_odes_ode_local_large_elp_open.path; - return await this.func.fileSendPost(url, data); + return this._projectRepo.openLargeLocalFile(data); } /** - * + * Open local ODE file + * Uses injected project repository (server or static mode) * @param {*} data * @returns */ async postLocalOdeFile(data) { - let url = this.endpoints.api_odes_ode_local_elp_open.path; - return await this.func.post(url, data); + return this._projectRepo.openLocalFile(data); } /** - * + * Get local XML properties file + * Uses injected project repository (server or static mode) * @param {*} data * @returns */ async postLocalXmlPropertiesFile(data) { - let url = this.endpoints.api_odes_ode_local_xml_properties_open.path; - return await this.func.post(url, data); + return this._projectRepo.getLocalProperties(data); } /** - * + * Import ELP to root + * Uses injected project repository (server or static mode) * @param {*} data * @returns */ async postImportElpToRoot(data) { - let url = this.endpoints.api_odes_ode_local_elp_import_root.path; - return await this.func.fileSendPost(url, data); + return this._projectRepo.importToRoot(data); } /** * Import a previously uploaded file into the root by server local path. - * Payload: { odeSessionId, odeFileName, odeFilePath } + * Uses injected project repository (server or static mode) * @param {Object} payload * @returns {Promise} */ async postImportElpToRootFromLocal(payload = {}) { - let url = - this.endpoints.api_odes_ode_local_elp_import_root_from_local?.path; - if (!url) { - // Fallback if route not yet defined - url = - this.apiUrlBase + - this.apiUrlBasePath + - '/api/ode-management/odes/ode/import/local/root'; - } - return await this.func.post(url, payload); + return this._projectRepo.importToRootFromLocal(payload); } /** - * + * Get local ODE components + * Uses injected project repository (server or static mode) * @param {*} data * @returns */ async postLocalOdeComponents(data) { - let url = this.endpoints.api_odes_ode_local_idevices_open.path; - return await this.func.post(url, data); + return this._projectRepo.getLocalComponents(data); } /** + * Open multiple local ODE files + * Uses injected project repository (server or static mode) * @param {*} data * @returns - * */ async postMultipleLocalOdeFiles(data) { - let url = this.endpoints.api_odes_ode_multiple_local_elp_open.path; - return await this.func.post(url, data); + return this._projectRepo.openMultipleLocalFiles(data); } /** - * + * Import ELP as child node + * Uses injected project repository (server or static mode) * @param {String} navId * @param {Object} payload * @returns */ async postImportElpAsChildFromLocal(navId, payload = {}) { - let url = this.endpoints.api_nav_structures_import_elp_child?.path; - if (!url) { - url = - this.apiUrlBase + - this.apiUrlBasePath + - '/api/nav-structure-management/nav-structures/{odeNavStructureSyncId}/import-elp'; - } - url = url.replace('{odeNavStructureSyncId}', navId); - return await this.func.post(url, payload); + return this._projectRepo.importAsChild(navId, payload); } // Backwards compatibility wrapper @@ -336,78 +383,69 @@ export default class ApiCallManager { } /** - * Post ode file to remove - * + * Delete ODE file + * Uses injected project repository (server or static mode) * @param {*} odeFileId * @returns */ async postDeleteOdeFile(odeFileId) { - let url = this.endpoints.api_odes_remove_ode_file.path; - return await this.func.post(url, odeFileId); + await this._projectRepo.delete(odeFileId); + return { responseMessage: 'OK' }; } /** - * + * Delete ODE files by date + * Uses injected project repository (server or static mode) * @param {*} params * @returns */ async postDeleteOdeFilesByDate(params) { - let url = this.endpoints.api_odes_remove_date_ode_files.path; - return await this.func.post(url, params); + return this._projectRepo.deleteByDate(params); } /** - * Post to check number of current ode users - * + * Check current ODE users + * Uses injected project repository (server or static mode) * @param {*} params * @returns - * */ async postCheckCurrentOdeUsers(params) { - let url = this.endpoints.api_odes_check_before_leave_ode_session.path; - return await this.func.post(url, params); + return this._projectRepo.checkCurrentUsers(params); } /** - * clean autosaves - * + * Clean autosaves by user + * Uses injected project repository (server or static mode) * @param {*} params * @returns - * */ async postCleanAutosavesByUser(params) { - let url = this.endpoints.api_odes_clean_init_autosave_elp.path; - return await this.func.post(url, params); + return this._projectRepo.cleanAutosaves(params); } /** - * Post session to close - * + * Close session + * Uses injected project repository (server or static mode) * @param {*} params * @returns - * */ async postCloseSession(params) { - let url = this.endpoints.api_odes_ode_session_close.path; - return await this.func.post(url, params); + return this._projectRepo.closeSession(params); } /** - * Import theme - * + * Upload theme + * Uses injected catalog adapter (server or static mode) * @param {*} params * @returns */ async postUploadTheme(params) { - let url = this.endpoints.api_themes_upload.path; - return await this.func.post(url, params); + return this._catalog.uploadTheme(params); } /** * Import theme from ELP file - * - * Uploads a packaged theme ZIP to the server for installation. - * The caller must package the theme files before calling this method. + * Uses injected catalog adapter (server or static mode) * * @param {Object} params * @param {string} params.themeDirname - Directory name of the theme @@ -415,315 +453,509 @@ export default class ApiCallManager { * @returns {Promise} Response with updated theme list */ async postOdeImportTheme(params) { - const url = `${this.apiUrlBase}${this.apiUrlBasePath}/api/themes/import`; - - // Theme ZIP is required - callers must package theme before calling - if (!params.themeZip) { - console.error('[API] postOdeImportTheme: themeZip parameter is required'); - return { - responseMessage: 'ERROR', - error: 'Theme import requires the theme files. Please package the theme before calling this method.', - }; - } - - if (!params.themeDirname) { - console.error('[API] postOdeImportTheme: themeDirname parameter is required'); - return { - responseMessage: 'ERROR', - error: 'Theme directory name is required.', - }; - } - - // Get auth token - const authToken = - eXeLearning?.app?.project?._yjsBridge?.authToken || - eXeLearning?.app?.auth?.getToken?.() || - eXeLearning?.config?.token || - localStorage.getItem('authToken'); - - // Create FormData - const formData = new FormData(); - formData.append('themeDirname', params.themeDirname); - formData.append('themeZip', params.themeZip, `${params.themeDirname}.zip`); - - try { - const response = await fetch(url, { - method: 'POST', - headers: { - ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), - }, - credentials: 'include', - body: formData, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { - responseMessage: 'ERROR', - error: errorData.error || `HTTP ${response.status}`, - }; - } - - return await response.json(); - } catch (error) { - console.error('[API] postOdeImportTheme error:', error); - return { responseMessage: 'ERROR', error: error.message }; - } + return this._catalog.importTheme(params); } /** - * Delete style - * + * Delete theme + * Uses injected catalog adapter (server or static mode) * @param {*} params * @returns */ async deleteTheme(params) { - let url = this.endpoints.api_themes_installed_delete.path; - return await this.func.delete(url, params); + return this._catalog.deleteTheme(params); } /** * Get installed theme zip - * + * Uses injected catalog adapter (server or static mode) * @param {*} odeSessionId - * @param {*} $themeDirName + * @param {*} themeDirName * @returns */ async getThemeZip(odeSessionId, themeDirName) { - let url = this.endpoints.api_themes_download.path; - url = url.replace('{odeSessionId}', odeSessionId); - url = url.replace('{themeDirName}', themeDirName); - return await this.func.get(url); + return this._catalog.getThemeZip(odeSessionId, themeDirName); } /** - * - * @param {*} themeConfig - * @param {*} themeRules + * Create new theme + * Uses injected catalog adapter (server or static mode) + * @param {*} params + * @returns */ async postNewTheme(params) { - let url = this.endpoints.api_themes_new.path; - return await this.func.post(url, params); + return this._catalog.createTheme(params); } /** - * + * Edit theme + * Uses injected catalog adapter (server or static mode) * @param {*} themeDir - * @param {*} themeConfig - * @param {*} themeRules + * @param {*} params + * @returns */ async putEditTheme(themeDir, params) { - let url = this.endpoints.api_themes_edit.path; - url = url.replace('{themeDirName}', themeDir); - return await this.func.put(url, params); + return this._catalog.updateTheme(themeDir, params); } /** - * Import idevice - * + * Upload iDevice + * Uses injected catalog adapter (server or static mode) * @param {*} params * @returns */ async postUploadIdevice(params) { - let url = this.endpoints.api_idevices_upload.path; - return await this.func.post(url, params); + return this._catalog.uploadIdevice(params); } /** - * Delete idevice installed - * + * Delete installed iDevice + * Uses injected catalog adapter (server or static mode) * @param {*} params * @returns */ async deleteIdeviceInstalled(params) { - let url = this.endpoints.api_idevices_installed_delete.path; - return await this.func.delete(url, params); + return this._catalog.deleteIdevice(params); } /** - * Get installed idevice zip - * + * Get installed iDevice zip + * Uses injected catalog adapter (server or static mode) * @param {*} odeSessionId - * @param {*} $ideviceDirName + * @param {*} ideviceDirName * @returns */ async getIdeviceInstalledZip(odeSessionId, ideviceDirName) { - let url = this.endpoints.api_idevices_installed_download.path; - url = url.replace('{odeSessionId}', odeSessionId); - url = url.replace('{ideviceDirName}', ideviceDirName); - return await this.func.get(url); + return this._catalog.getIdeviceZip(odeSessionId, ideviceDirName); } /** - * Accept LOPD + * Accept LOPD (data protection) + * Uses injected user preferences adapter (server or static mode) * * @returns */ async postUserSetLopdAccepted() { - let url = this.endpoints.api_user_set_lopd_accepted.path; - return await this.func.post(url); + return this._userPreferences.acceptLopd(); } /** * Get user preferences + * Uses injected user preferences adapter (server or static mode) * * @returns */ async getUserPreferences() { - let url = this.endpoints.api_user_preferences_get.path; - return await this.func.get(url); + return this._userPreferences.getPreferences(); } /** * Save user preferences + * Uses injected user preferences adapter (server or static mode) * * @param {*} params * @returns */ async putSaveUserPreferences(params) { - let url = this.endpoints.api_user_preferences_save.path; - return await this.func.put(url, params); + return this._userPreferences.savePreferences(params); } /** - * Get ode last update - * + * Get ODE last updated + * Uses injected project repository (server or static mode) * @param {*} odeId * @returns */ async getOdeLastUpdated(odeId) { - let url = this.endpoints.api_odes_last_updated.path; - url = url.replace('{odeId}', odeId); - return await this.func.get(url); + return this._projectRepo.getLastUpdated(odeId); } /** - * get ode concurrent users - * + * Get ODE concurrent users + * Uses injected project repository (server or static mode) * @param {*} odeId * @param {*} versionId * @param {*} sessionId * @returns */ async getOdeConcurrentUsers(odeId, versionId, sessionId) { - let url = this.endpoints.api_odes_current_users.path; - url = url.replace('{odeId}', odeId); - url = url.replace('{odeVersionId}', versionId); - url = url.replace('{odeSessionId}', sessionId); - return await this.func.get(url, null, false); + const result = await this._projectRepo.getConcurrentUsers(odeId, versionId, sessionId); + return { currentUsers: result.users?.length || 0, users: result.users || [] }; } /** - * get ode structure - * + * Get ODE structure + * Uses injected project repository (server or static mode) * @param {*} versionId * @param {*} sessionId * @returns */ async getOdeStructure(versionId, sessionId) { - let url = this.endpoints.api_nav_structures_nav_structure_get.path; - url = url.replace('{odeVersionId}', versionId); - url = url.replace('{odeSessionId}', sessionId); - return await this.func.get(url); + return this._projectRepo.getStructure(versionId, sessionId); } /** - * Get ode broken links - * + * Get ODE session broken links + * Uses injected link validation adapter (server or static mode) * @param {*} params * @returns */ async getOdeSessionBrokenLinks(params) { - let url = this.endpoints.api_odes_session_get_broken_links.path; - return await this.func.postJson(url, params); + return this._linkValidation.getSessionBrokenLinks(params); } /** - * Extract links from idevices for validation (fast, no validation) - * + * Extract links from iDevices for validation + * Extracts links from Yjs content (always available) * @param {Object} params - { odeSessionId, idevices } * @returns {Promise} - { responseMessage, links, totalLinks } */ async extractLinksForValidation(params) { - const url = `${this.apiUrlBase}${this.apiUrlBasePath}/api/ode-management/odes/session/brokenlinks/extract`; - return await this.func.postJson(url, params); + return this._extractLinksFromYjs(); + } + + /** + * Extract links from Yjs document by scanning all content. + * @private + * @returns {Promise<{responseMessage: string, links: Array, totalLinks: number}>} + */ + _extractLinksFromYjs() { + const projectManager = eXeLearning?.app?.project; + const bridge = projectManager?._yjsBridge; + const structureBinding = bridge?.structureBinding; + + if (!structureBinding) { + console.warn('[apiCallManager] _extractLinksFromYjs: No structureBinding available'); + return { responseMessage: 'OK', links: [], totalLinks: 0 }; + } + + const links = []; + const linkCounts = new Map(); // Track link occurrences by URL + + // Regex to find URLs in HTML content + const urlRegex = /href=["']([^"']+)["']/gi; + + // Get all pages + const pages = structureBinding.getPages() || []; + + for (const page of pages) { + const pageId = page.id; + const pageName = page.pageName || 'Page'; + + // Get blocks for this page + const blocks = structureBinding.getBlocks(pageId) || []; + + for (const block of blocks) { + const blockName = block.blockName || ''; + + // Get components for this block + const components = structureBinding.getComponents(pageId, block.id) || []; + + for (const component of components) { + const htmlContent = component.htmlContent || ''; + const ideviceType = component.ideviceType || ''; + const order = component.order || 0; + + // Find all href URLs + let match; + while ((match = urlRegex.exec(htmlContent)) !== null) { + const url = match[1]; + + // Skip internal anchors, asset URLs, and internal navigation links + if (url.startsWith('#') || url.startsWith('asset://') || + url.startsWith('data:') || url.startsWith('blob:') || + url.startsWith('javascript:') || url.startsWith('exe-node:')) { + continue; + } + + // Track count for this URL + const count = (linkCounts.get(url) || 0) + 1; + linkCounts.set(url, count); + + // Generate unique ID + const linkId = `link-${crypto.randomUUID().substring(0, 8)}`; + + links.push({ + id: linkId, + url: url, + count: count, + pageName: pageName, + blockName: blockName, + ideviceType: ideviceType.replace('Idevice', ''), + order: order, + }); + } + + // Reset regex lastIndex for next iteration + urlRegex.lastIndex = 0; + } + } + } + + // Update counts in all links (same URL should show total count) + for (const link of links) { + link.count = linkCounts.get(link.url) || 1; + } + + console.log('[apiCallManager] _extractLinksFromYjs: Found', links.length, 'links'); + return { responseMessage: 'OK', links, totalLinks: links.length }; } /** * Get the URL for the link validation stream endpoint - * - * @returns {string} + * Uses injected link validation adapter (server or static mode) + * @returns {string|null} */ getLinkValidationStreamUrl() { - return `${this.apiUrlBase}${this.apiUrlBasePath}/api/ode-management/odes/session/brokenlinks/validate-stream`; + return this._linkValidation.getValidationStreamUrl(); } /** * Get page broken links - * + * Uses injected link validation adapter (server or static mode) * @param {*} pageId * @returns */ async getOdePageBrokenLinks(pageId) { - let url = this.endpoints.api_odes_pag_get_broken_links.path; - url = url.replace('{odePageId}', pageId); - return await this.func.get(url); + return this._linkValidation.getPageBrokenLinks(pageId); } /** * Get block broken links - * - * @param {*} BlockId + * Uses injected link validation adapter (server or static mode) + * @param {*} blockId * @returns */ async getOdeBlockBrokenLinks(blockId) { - let url = this.endpoints.api_odes_block_get_broken_links.path; - url = url.replace('{odeBlockId}', blockId); - return await this.func.get(url); + return this._linkValidation.getBlockBrokenLinks(blockId); } /** - * Get idevice broken links - * - * @param {*} IdeviceId + * Get iDevice broken links + * Uses injected link validation adapter (server or static mode) + * @param {*} ideviceId * @returns */ async getOdeIdeviceBrokenLinks(ideviceId) { - let url = this.endpoints.api_odes_idevice_get_broken_links.path; - url = url.replace('{odeIdeviceId}', ideviceId); - return await this.func.get(url); + return this._linkValidation.getIdeviceBrokenLinks(ideviceId); } /** - * + * Get ODE properties + * Uses injected project repository (server or static mode) * @param {*} odeSessionId * @returns */ async getOdeProperties(odeSessionId) { - let url = this.endpoints.api_odes_properties_get.path; - url = url.replace('{odeSessionId}', odeSessionId); - return await this.func.get(url); + return this._projectRepo.getProperties(odeSessionId); } /** - * - * @param {*} odeId + * Save ODE properties + * Uses injected project repository (server or static mode) + * @param {*} params * @returns */ async putSaveOdeProperties(params) { - let url = this.endpoints.api_odes_properties_save.path; - return await this.func.put(url, params); + return this._projectRepo.saveProperties(params); } /** - * Get ode used files - * + * Get ODE session used files + * Gets assets from Yjs AssetManager (always available) * @param {*} params - * @returns + * @returns {Promise<{usedFiles: Array}>} */ async getOdeSessionUsedFiles(params) { - let url = this.endpoints.api_odes_session_get_used_files.path; - return await this.func.postJson(url, params); + return this._getUsedFilesFromYjs(); + } + + /** + * Extract used files from Yjs document by scanning all content. + * @private + * @returns {Promise<{responseMessage: string, usedFiles: Array}>} + */ + async _getUsedFilesFromYjs() { + const projectManager = eXeLearning?.app?.project; + const bridge = projectManager?._yjsBridge; + const structureBinding = bridge?.structureBinding; + const assetManager = bridge?.assetManager; + + if (!structureBinding) { + console.warn('[apiCallManager] _getUsedFilesFromYjs: No structureBinding available'); + return { responseMessage: 'OK', usedFiles: [] }; + } + + const usedFiles = []; + const seenAssets = new Set(); // Track unique assets + const assetUsageMap = new Map(); // Track where each asset is used: assetId -> {pageName, blockName, ideviceType, order} + + // Regex to find asset URLs in HTML content + const assetRegex = /asset:\/\/([a-f0-9-]+)/gi; + + // Step 1: Scan all content to find where each asset is used + const pages = structureBinding.getPages() || []; + console.log('[apiCallManager] _getUsedFilesFromYjs: Scanning', pages.length, 'pages for asset usage'); + + for (const page of pages) { + const pageId = page.id; + const pageName = page.pageName || 'Page'; + + // Get blocks for this page + const blocks = structureBinding.getBlocks(pageId) || []; + + for (const block of blocks) { + const blockName = block.blockName || ''; + + // Get components for this block + const components = structureBinding.getComponents(pageId, block.id) || []; + + for (const component of components) { + const ideviceType = component.ideviceType || ''; + const order = component.order || 0; + + // Access raw HTML content from Y.Map (before URL resolution) + // component._ymap contains the original Y.Map with asset:// URLs + let rawHtmlContent = ''; + let rawJsonProperties = ''; + + if (component._ymap) { + const rawHtml = component._ymap.get('htmlContent'); + if (rawHtml && typeof rawHtml.toString === 'function') { + rawHtmlContent = rawHtml.toString(); + } else if (typeof rawHtml === 'string') { + rawHtmlContent = rawHtml; + } + // Also check htmlView as fallback + if (!rawHtmlContent) { + const htmlView = component._ymap.get('htmlView'); + if (typeof htmlView === 'string') { + rawHtmlContent = htmlView; + } + } + // Check jsonProperties for assets too + const jsonProps = component._ymap.get('jsonProperties'); + if (typeof jsonProps === 'string') { + rawJsonProperties = jsonProps; + } + } + + // Combine htmlContent and jsonProperties for scanning + const contentToScan = rawHtmlContent + ' ' + rawJsonProperties; + + // Find asset:// URLs and record their location + let match; + while ((match = assetRegex.exec(contentToScan)) !== null) { + const assetId = match[1]; + // Only store first occurrence location + if (!assetUsageMap.has(assetId)) { + assetUsageMap.set(assetId, { + pageName, + blockName, + ideviceType: ideviceType.replace('Idevice', ''), + order, + }); + } + } + + // Reset regex lastIndex for next iteration + assetRegex.lastIndex = 0; + } + } + } + + console.log('[apiCallManager] _getUsedFilesFromYjs: Found', assetUsageMap.size, 'assets referenced in content'); + + // Step 2: Get all assets from AssetManager and combine with usage info + if (assetManager) { + try { + const allAssets = assetManager.getAllAssetsMetadata?.() || []; + console.log('[apiCallManager] _getUsedFilesFromYjs: Found', allAssets.length, 'total assets in AssetManager'); + + for (const asset of allAssets) { + const assetId = asset.id || asset.uuid; + if (!assetId) continue; + + const assetUrl = `asset://${assetId}`; + if (seenAssets.has(assetUrl)) continue; + seenAssets.add(assetUrl); + + const fileName = asset.name || asset.filename || assetId.substring(0, 8) + '...'; + const fileSize = asset.size ? this._formatFileSize(asset.size) : ''; + + // Get usage location if available + const usage = assetUsageMap.get(assetId); + + usedFiles.push({ + usedFiles: fileName, + usedFilesPath: assetUrl, + usedFilesSize: fileSize, + pageNamesUsedFiles: usage?.pageName || '-', + blockNamesUsedFiles: usage?.blockName || '-', + typeComponentSyncUsedFiles: usage?.ideviceType || '-', + orderComponentSyncUsedFiles: usage?.order || 0, + }); + } + } catch (e) { + console.debug('[apiCallManager] Could not get assets from AssetManager:', e); + } + } + + // Step 3: Add any assets found in content but not in AssetManager (shouldn't happen normally) + for (const [assetId, usage] of assetUsageMap.entries()) { + const assetUrl = `asset://${assetId}`; + if (seenAssets.has(assetUrl)) continue; + seenAssets.add(assetUrl); + + // Try to get metadata from AssetManager + let fileName = assetId.substring(0, 8) + '...'; + let fileSize = ''; + + if (assetManager) { + try { + const asset = await assetManager.getAsset(assetId); + if (asset) { + fileName = asset.name || asset.filename || fileName; + if (asset.blob?.size) { + fileSize = this._formatFileSize(asset.blob.size); + } else if (asset.size) { + fileSize = this._formatFileSize(asset.size); + } + } + } catch (e) { + console.debug('[apiCallManager] Could not get asset metadata:', assetId, e); + } + } + + usedFiles.push({ + usedFiles: fileName, + usedFilesPath: assetUrl, + usedFilesSize: fileSize, + pageNamesUsedFiles: usage.pageName, + blockNamesUsedFiles: usage.blockName, + typeComponentSyncUsedFiles: usage.ideviceType, + orderComponentSyncUsedFiles: usage.order, + }); + } + + console.log('[apiCallManager] _getUsedFilesFromYjs: Returning', usedFiles.length, 'assets total'); + return { responseMessage: 'OK', usedFiles }; + } + + /** + * Format file size in human-readable format. + * @private + */ + _formatFileSize(bytes) { + if (!bytes || bytes === 0) return ''; + const units = ['B', 'KB', 'MB', 'GB']; + let unitIndex = 0; + let size = bytes; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + return `${size.toFixed(1)} ${units[unitIndex]}`; } /** @@ -740,25 +972,15 @@ export default class ApiCallManager { } /** - * Download ode export + * Download ODE export + * Uses injected export adapter (server or static mode) * - * @param {*} params + * @param {*} odeSessionId + * @param {*} exportType * @returns */ async getOdeExportDownload(odeSessionId, exportType) { - let url = this.endpoints.api_ode_export_download.path; - url = url.replace('{odeSessionId}', odeSessionId); - url = url.replace('{exportType}', exportType); - - // Check if this is a Yjs session - send structure via POST - if (odeSessionId && odeSessionId.startsWith('yjs-')) { - const structure = this.buildStructureFromYjs(); - if (structure) { - return await this.func.post(url, { structure }); - } - } - - return await this.func.get(url); + return this._exportAdapter.downloadExport(odeSessionId, exportType); } /** @@ -870,112 +1092,96 @@ export default class ApiCallManager { } /** - * Preview ode export + * Preview ODE export + * Uses injected export adapter (server or static mode) * - * @param {*} params + * @param {*} odeSessionId * @returns */ async getOdePreviewUrl(odeSessionId) { - let url = this.endpoints.api_ode_export_preview.path; - url = url.replace('{odeSessionId}', odeSessionId); - - return await this.func.get(url); + return this._exportAdapter.getPreviewUrl(odeSessionId); } /** - * download idevice/block content + * Download iDevice/block content + * Uses injected export adapter (server or static mode) * - * @param {*} params + * @param {*} odeSessionId + * @param {*} odeBlockId + * @param {*} odeIdeviceId * @returns */ async getOdeIdevicesDownload(odeSessionId, odeBlockId, odeIdeviceId) { - let downloadResponse = []; - let url = this.endpoints.api_idevices_download_ode_components.path; - - downloadResponse['url'] = url.replace('{odeSessionId}', odeSessionId); - downloadResponse['url'] = downloadResponse['url'].replace( - '{odeBlockId}', - odeBlockId - ); - downloadResponse['url'] = downloadResponse['url'].replace( - '{odeIdeviceId}', - odeIdeviceId - ); - downloadResponse['response'] = await this.func.getText( - downloadResponse['url'] - ); - - return downloadResponse; + return this._exportAdapter.downloadIDevice(odeSessionId, odeBlockId, odeIdeviceId); } /** - * Force to download file resources (case xml) - * Only gets url + * Force download file resources + * Uses injected assets adapter (server or static mode) * * @param {*} resource * @returns */ async getFileResourcesForceDownload(resource) { - let downloadResponse = []; - let url = - this.endpoints.api_idevices_force_download_file_resources.path; - downloadResponse['url'] = url + '?resource=' + resource; - return downloadResponse; + return this._assets.getDownloadUrl(resource); } /** - * Save ode + * Save ODE + * Uses injected project repository (server or static mode) * * @param {*} params * @returns */ async postOdeSave(params) { - let url = this.endpoints.api_odes_ode_save_manual.path; - return await this.func.post(url, params); + const sessionId = params?.odeSessionId || window.eXeLearning?.odeSessionId; + return this._projectRepo.save(sessionId, params); } /** - * Autosave ode + * Autosave ODE + * Uses injected project repository (server or static mode) * * @param {*} params * @returns */ async postOdeAutosave(params) { - let url = this.endpoints.api_odes_ode_save_auto.path; - this.func.post(url, params); + const sessionId = params?.odeSessionId || window.eXeLearning?.odeSessionId; + return this._projectRepo.autoSave(sessionId, params); } /** - * Save as ode + * Save ODE as new file + * Uses injected project repository (server or static mode) * * @param {*} params * @returns */ async postOdeSaveAs(params) { - let url = this.endpoints.api_odes_ode_save_as.path; - return await this.func.post(url, params); + const sessionId = params?.odeSessionId || window.eXeLearning?.odeSessionId; + return this._projectRepo.saveAs(sessionId, params); } /** - * Upload new elp to first type platform + * Upload new ELP to first type platform + * Uses injected platform integration adapter (server or static mode) * * @param {*} params * @returns */ async postFirstTypePlatformIntegrationElpUpload(params) { - let url = this.endpoints.set_platform_new_ode.path; - return await this.func.post(url, params); + return this._platformIntegration.uploadElp(params); } /** - * Open elp from platform + * Open ELP from platform + * Uses injected platform integration adapter (server or static mode) * * @param {*} params * @returns */ async platformIntegrationOpenElp(params) { - let url = this.endpoints.open_platform_elp.path; - return await this.func.post(url, params); + return this._platformIntegration.openElp(params); } /** @@ -1025,98 +1231,105 @@ export default class ApiCallManager { } /** + * Obtain ODE block sync + * In static mode, Yjs handles all sync * * @param {*} params * @returns */ async postObtainOdeBlockSync(params) { - let url = this.endpoints.get_current_block_update.path; - return await this.func.post(url, params); + return this._collaboration.obtainBlockSync(params); } /** * Get all translations + * Uses injected catalog adapter (server or static mode) * - * @param {*} locale * @returns */ async getTranslationsAll() { - let url = this.endpoints.api_translations_lists.path; - return await this.func.get(url); + const locales = await this._catalog.getLocales(); + const localeCodes = Array.isArray(locales) + ? locales.map(l => l.code || l) + : ['en']; + return { + locales: localeCodes, + packageLocales: localeCodes, + defaultLocale: 'en', + }; } /** * Get translations + * Uses injected catalog adapter (server or static mode) * * @param {*} locale * @returns */ async getTranslations(locale) { - let url = this.endpoints.api_translations_list_by_locale.path; - url = url.replace('{locale}', locale); - return await this.func.get(url); + return this._catalog.getTranslations(locale); } /** - * Get login url of Google Drive + * Get login URL of Google Drive + * Uses injected cloud storage adapter (server or static mode) * * @returns */ async getUrlLoginGoogleDrive() { - let url = this.endpoints.api_google_oauth_login_url_get.path; - return await this.func.get(url); + return this._cloudStorage.getGoogleDriveLoginUrl(); } /** * Get folders of Google Drive account + * Uses injected cloud storage adapter (server or static mode) * * @returns */ async getFoldersGoogleDrive() { - let url = this.endpoints.api_google_drive_folders_list.path; - return await this.func.get(url); + return this._cloudStorage.getGoogleDriveFolders(); } /** * Upload file to Google Drive + * Uses injected cloud storage adapter (server or static mode) * * @param {*} params * @returns */ async uploadFileGoogleDrive(params) { - let url = this.endpoints.api_google_drive_file_upload.path; - return await this.func.post(url, params); + return this._cloudStorage.uploadToGoogleDrive(params); } /** - * Get login url of Dropbox + * Get login URL of Dropbox + * Uses injected cloud storage adapter (server or static mode) * * @returns */ async getUrlLoginDropbox() { - let url = this.endpoints.api_dropbox_oauth_login_url_get.path; - return await this.func.get(url); + return this._cloudStorage.getDropboxLoginUrl(); } /** * Get folders of Dropbox account + * Uses injected cloud storage adapter (server or static mode) * * @returns */ async getFoldersDropbox() { - let url = this.endpoints.api_dropbox_folders_list.path; - return await this.func.get(url); + return this._cloudStorage.getDropboxFolders(); } /** * Upload file to Dropbox + * Uses injected cloud storage adapter (server or static mode) * * @param {*} params * @returns */ async uploadFileDropbox(params) { - let url = this.endpoints.api_dropbox_file_upload.path; - return await this.func.post(url, params); + return this._cloudStorage.uploadToDropbox(params); } /** @@ -1150,7 +1363,19 @@ export default class ApiCallManager { return this._getComponentsByPageFromYjs(odeNavStructureSyncId); } - let url = this.endpoints.api_idevices_list_by_page.path; + // Check if endpoint is available + const endpoint = this.endpoints?.api_idevices_list_by_page; + if (!endpoint?.path) { + console.warn('[apiCallManager] getComponentsByPage: Endpoint not available, returning empty structure'); + return { + id: odeNavStructureSyncId, + odePageId: odeNavStructureSyncId, + pageName: 'Page', + odePagStructureSyncs: [] + }; + } + + let url = endpoint.path; url = url.replace('{odeNavStructureSyncId}', odeNavStructureSyncId); return await this.func.get(url); } @@ -1280,27 +1505,25 @@ export default class ApiCallManager { } /** - * Get html template of idevice + * Get HTML template of iDevice + * Uses injected catalog adapter (server or static mode) * * @param {*} odeNavStructureSyncId * @returns */ async getComponentHtmlTemplate(odeNavStructureSyncId) { - let url = this.endpoints.api_idevices_html_template_get.path; - url = url.replace('{odeComponentsSyncId}', odeNavStructureSyncId); - return await this.func.get(url); + return this._catalog.getComponentHtmlTemplate(odeNavStructureSyncId); } /** - * Get idevice html saved + * Get iDevice HTML saved + * Uses injected catalog adapter (server or static mode) * - * @param {*} params + * @param {*} odeComponentsSyncId * @returns */ async getSaveHtmlView(odeComponentsSyncId) { - let url = this.endpoints.api_idevices_html_view_get.path; - url.replace('{odeComponentsSyncId}', odeComponentsSyncId); - return await this.func.get(url); + return this._catalog.getSaveHtmlView(odeComponentsSyncId); } /** @@ -1568,11 +1791,21 @@ export default class ApiCallManager { /** * Reorder idevice + * In static mode, handled by Yjs structure binding * * @param {*} params * @returns */ async putReorderIdevice(params) { + // Use injected content adapter if available (new pattern) + if (this._content) { + try { + return await this._content.reorderIdevice(params); + } catch (error) { + console.error('[API] putReorderIdevice via content adapter error:', error); + } + } + let url = this.endpoints.api_idevices_idevice_reorder.path; return await this.func.put(url, params); } @@ -1698,11 +1931,21 @@ export default class ApiCallManager { /** * Reorder block + * In static mode, handled by Yjs structure binding * * @param {*} params * @returns */ async putReorderBlock(params) { + // Use injected content adapter if available (new pattern) + if (this._content) { + try { + return await this._content.reorderBlock(params); + } catch (error) { + console.error('[API] putReorderBlock via content adapter error:', error); + } + } + // Note: Yjs reordering is handled by blockNode.reorderViaYjs() before this is called // This method is only used for legacy API mode let url = this.endpoints.api_pag_structures_pag_structure_reorder.path; @@ -1711,11 +1954,21 @@ export default class ApiCallManager { /** * Delete block + * In static mode, handled by Yjs structure binding * * @param {*} blockId * @returns */ async deleteBlock(blockId) { + // Use injected content adapter if available (new pattern) + if (this._content) { + try { + return await this._content.deleteBlock(blockId); + } catch (error) { + console.error('[API] deleteBlock via content adapter error:', error); + } + } + let url = this.endpoints.api_pag_structures_pag_structure_delete.path; url = url.replace('{odePagStructureSyncId}', blockId); return await this.func.delete(url); @@ -1723,11 +1976,21 @@ export default class ApiCallManager { /** * Save page node + * In static mode, handled by Yjs * * @param {*} params * @returns */ async putSavePage(params) { + // Use injected content adapter if available (new pattern) + if (this._content) { + try { + return await this._content.savePage(params); + } catch (error) { + console.error('[API] putSavePage via content adapter error:', error); + } + } + let url = this.endpoints.api_nav_structures_nav_structure_data_save.path; return await this.func.put(url, params); @@ -1787,22 +2050,42 @@ export default class ApiCallManager { /** * Reorder page node + * In static mode, handled by Yjs * * @param {*} params * @returns */ async putReorderPage(params) { + // Use injected content adapter if available (new pattern) + if (this._content) { + try { + return await this._content.reorderPage(params); + } catch (error) { + console.error('[API] putReorderPage via content adapter error:', error); + } + } + let url = this.endpoints.api_nav_structures_nav_structure_reorder.path; return await this.func.put(url, params); } /** * Duplicate page + * In static mode, handled by Yjs structure binding * * @param {*} params * @returns */ async postClonePage(params) { + // Use injected content adapter if available (new pattern) + if (this._content) { + try { + return await this._content.clonePage(params); + } catch (error) { + console.error('[API] postClonePage via content adapter error:', error); + } + } + let url = this.endpoints.api_nav_structures_nav_structure_duplicate.path; return await this.func.post(url, params); @@ -1810,11 +2093,21 @@ export default class ApiCallManager { /** * Delete page node + * In static mode, handled by Yjs * * @param {*} blockId * @returns */ async deletePage(pageId) { + // Use injected content adapter if available (new pattern) + if (this._content) { + try { + return await this._content.deletePage(pageId); + } catch (error) { + console.error('[API] deletePage via content adapter error:', error); + } + } + let url = this.endpoints.api_nav_structures_nav_structure_delete.path; url = url.replace('{odeNavStructureSyncId}', pageId); return await this.func.delete(url); @@ -1822,33 +2115,65 @@ export default class ApiCallManager { /** * Upload file + * In static mode, files are stored via AssetManager in IndexedDB * * @param {*} params * @returns */ async postUploadFileResource(params) { + // Use injected assets adapter if available (new pattern) + if (this._assets && params.file && params.projectId) { + try { + const result = await this._assets.upload(params.projectId, params.file, params.path || ''); + return { responseMessage: 'OK', ...result }; + } catch (error) { + console.error('[API] postUploadFileResource via adapter error:', error); + return { responseMessage: 'ERROR', error: error.message }; + } + } + let url = this.endpoints.api_idevices_upload_file_resources.path; return await this.func.post(url, params); } /** * Upload large file + * In static mode, files are stored via AssetManager in IndexedDB * * @param {*} params * @returns */ async postUploadLargeFileResource(params) { + // Use injected assets adapter if available (new pattern) + if (this._assets && params.file && params.projectId) { + try { + const result = await this._assets.upload(params.projectId, params.file, params.path || ''); + return { responseMessage: 'OK', ...result }; + } catch (error) { + console.error('[API] postUploadLargeFileResource via adapter error:', error); + return { responseMessage: 'ERROR', error: error.message }; + } + } + let url = this.endpoints.api_idevices_upload_large_file_resources.path; return await this.func.fileSendPost(url, params); } /** * Base api func call + * In static mode, returns error since no server is available * * @param {*} endpointId * @param {*} params */ async send(endpointId, params) { + // Generic API calls are server-only (no adapter pattern for this) + // In static mode, endpoints won't be available so this will fail gracefully + if (!this.endpoints[endpointId]) { + console.warn('[apiCallManager] Endpoint not found:', endpointId); + return { responseMessage: 'NOT_SUPPORTED' }; + } + let url = this.endpoints[endpointId].path; let method = this.endpoints[endpointId].method; return await this.func.do(method, url, params); @@ -1856,11 +2181,21 @@ export default class ApiCallManager { /** * Games get idevices by session ID + * In static mode, returns empty list * * @param {string} odeSessionId * @returns {Promise} */ async getIdevicesBySessionId(odeSessionId) { + // Use injected catalog adapter if available (new pattern) + if (this._catalog) { + try { + return await this._catalog.getIdevicesBySessionId(odeSessionId); + } catch (error) { + console.error('[API] getIdevicesBySessionId via catalog adapter error:', error); + } + } + let url = this.endpoints.api_games_session_idevices.path; url = url.replace('{odeSessionId}', odeSessionId); return await this.func.get(url); @@ -1898,11 +2233,21 @@ export default class ApiCallManager { /** * Get project sharing information (owner, collaborators, visibility) * Accepts both numeric ID and UUID + * In static mode, sharing not available * * @param {number|string} projectId - The project ID or UUID * @returns {Promise} Response with project sharing info */ async getProject(projectId) { + // Use injected sharing adapter if available (new pattern) + if (this._sharing) { + try { + return await this._sharing.getProject(projectId); + } catch (error) { + console.error('[API] getProject via adapter error:', error); + } + } + const url = this._buildProjectUrl(projectId, '/sharing'); const authToken = @@ -1937,49 +2282,19 @@ export default class ApiCallManager { /** * Update project visibility - * Accepts both numeric ID and UUID + * Uses injected sharing adapter (server or static mode) * * @param {number|string} projectId - The project ID or UUID * @param {string} visibility - 'public' or 'private' * @returns {Promise} Response with updated project */ async updateProjectVisibility(projectId, visibility) { - const url = this._buildProjectUrl(projectId, '/visibility'); - - const authToken = - eXeLearning?.app?.project?._yjsBridge?.authToken || - eXeLearning?.app?.auth?.getToken?.() || - localStorage.getItem('authToken'); - - try { - const response = await fetch(url, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), - }, - credentials: 'include', - body: JSON.stringify({ visibility }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { - responseMessage: 'ERROR', - detail: errorData.message || `HTTP ${response.status}`, - }; - } - - return await response.json(); - } catch (error) { - console.error('[API] updateProjectVisibility error:', error); - return { responseMessage: 'ERROR', detail: error.message }; - } + return this._sharing.updateVisibility(projectId, visibility); } /** * Add a collaborator to a project - * Accepts both numeric ID and UUID + * Uses injected sharing adapter (server or static mode) * * @param {number|string} projectId - The project ID or UUID * @param {string} email - The collaborator's email @@ -1987,96 +2302,43 @@ export default class ApiCallManager { * @returns {Promise} Response */ async addProjectCollaborator(projectId, email, role = 'editor') { - const url = this._buildProjectUrl(projectId, '/collaborators'); - - const authToken = - eXeLearning?.app?.project?._yjsBridge?.authToken || - eXeLearning?.app?.auth?.getToken?.() || - localStorage.getItem('authToken'); - - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), - }, - credentials: 'include', - body: JSON.stringify({ email, role }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - // Map common error codes - if (response.status === 404) { - return { responseMessage: 'USER_NOT_FOUND', detail: errorData.message }; - } - if (response.status === 400 && errorData.message?.includes('already')) { - return { responseMessage: 'ALREADY_COLLABORATOR', detail: errorData.message }; - } - return { - responseMessage: 'ERROR', - detail: errorData.message || `HTTP ${response.status}`, - }; - } - - return await response.json(); - } catch (error) { - console.error('[API] addProjectCollaborator error:', error); - return { responseMessage: 'ERROR', detail: error.message }; - } + return this._sharing.addCollaborator(projectId, email, role); } /** * Remove a collaborator from a project - * Accepts both numeric ID and UUID + * Uses injected sharing adapter (server or static mode) * * @param {number|string} projectId - The project ID or UUID * @param {number} userId - The collaborator's user ID * @returns {Promise} Response */ async removeProjectCollaborator(projectId, userId) { - const url = this._buildProjectUrl(projectId, `/collaborators/${userId}`); - - const authToken = - eXeLearning?.app?.project?._yjsBridge?.authToken || - eXeLearning?.app?.auth?.getToken?.() || - localStorage.getItem('authToken'); - - try { - const response = await fetch(url, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), - }, - credentials: 'include', - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { - responseMessage: 'ERROR', - detail: errorData.message || `HTTP ${response.status}`, - }; - } - - return await response.json(); - } catch (error) { - console.error('[API] removeProjectCollaborator error:', error); - return { responseMessage: 'ERROR', detail: error.message }; - } + return this._sharing.removeCollaborator(projectId, userId); } /** * Transfer project ownership to another user * Accepts both numeric ID and UUID + * In static mode, sharing not available * * @param {number|string} projectId - The project ID or UUID * @param {number} newOwnerId - The new owner's user ID * @returns {Promise} Response with updated project */ async transferProjectOwnership(projectId, newOwnerId) { + // Use injected sharing adapter if available (new pattern) + if (this._sharing) { + try { + return await this._sharing.transferOwnership(projectId, newOwnerId); + } catch (error) { + console.error( + '[API] transferProjectOwnership via adapter error:', + error + ); + } + } + const url = this._buildProjectUrl(projectId, '/owner'); const authToken = diff --git a/public/app/rest/apiCallManager.test.js b/public/app/rest/apiCallManager.test.js index 432d16ffc..0baad5e9c 100644 --- a/public/app/rest/apiCallManager.test.js +++ b/public/app/rest/apiCallManager.test.js @@ -7,6 +7,9 @@ describe('ApiCallManager', () => { let apiManager; let mockApp; let mockFunc; + let mockCatalog; + let mockProjectRepo; + let mockAssets; beforeEach(() => { // Mock localStorage @@ -30,6 +33,136 @@ describe('ApiCallManager', () => { common: { getVersionTimeStamp: vi.fn(() => '123456'), }, + // Mock isStaticMode for static mode checks in apiCallManager + isStaticMode: vi.fn(() => false), + dataProvider: { + getApiParameters: vi.fn(), + getTranslations: vi.fn(), + getInstalledIdevices: vi.fn(), + getInstalledThemes: vi.fn(), + }, + }; + + // Mock adapters for the new ports/adapters pattern + mockCatalog = { + getApiParameters: vi.fn().mockResolvedValue({ routes: {} }), + getChangelog: vi.fn().mockResolvedValue('changelog content'), + getUploadLimits: vi.fn().mockResolvedValue({ maxFileSize: 1024 }), + getThirdPartyCode: vi.fn().mockResolvedValue('third party code'), + getLicensesList: vi.fn().mockResolvedValue('licenses'), + getIDevices: vi.fn().mockResolvedValue([]), + getThemes: vi.fn().mockResolvedValue([]), + getTemplates: vi.fn().mockResolvedValue({ templates: [] }), + getLocales: vi.fn().mockResolvedValue(['en', 'es']), + getTranslations: vi.fn().mockResolvedValue({}), + getComponentHtmlTemplate: vi.fn().mockResolvedValue({ responseMessage: 'OK', htmlTemplate: '' }), + getSaveHtmlView: vi.fn().mockResolvedValue({ responseMessage: 'OK', htmlView: '' }), + getIdevicesBySessionId: vi.fn().mockResolvedValue([]), + // Theme methods + uploadTheme: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + importTheme: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + deleteTheme: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + createTheme: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + updateTheme: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + getThemeZip: vi.fn().mockResolvedValue(new Blob(['theme'])), + // iDevice methods + uploadIdevice: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + deleteIdevice: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + getIdeviceZip: vi.fn().mockResolvedValue(new Blob(['idevice'])), + }; + + mockProjectRepo = { + list: vi.fn().mockResolvedValue([]), + getRecent: vi.fn().mockResolvedValue([]), + openFile: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + delete: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + save: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + joinSession: vi.fn().mockResolvedValue({ available: true }), + openLargeLocalFile: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + openLocalFile: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + getLocalProperties: vi.fn().mockResolvedValue({ responseMessage: 'OK', properties: {} }), + importToRoot: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + importToRootFromLocal: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + getLocalComponents: vi.fn().mockResolvedValue({ responseMessage: 'OK', components: [] }), + openMultipleLocalFiles: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + importAsChild: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + deleteByDate: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + checkCurrentUsers: vi.fn().mockResolvedValue({ responseMessage: 'OK', currentUsers: 0 }), + cleanAutosaves: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + closeSession: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + getLastUpdated: vi.fn().mockResolvedValue({ lastUpdated: new Date().toISOString() }), + getConcurrentUsers: vi.fn().mockResolvedValue({ users: [] }), + getStructure: vi.fn().mockResolvedValue({ structure: null }), + getProperties: vi.fn().mockResolvedValue({ responseMessage: 'OK', properties: {} }), + saveProperties: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + autoSave: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + saveAs: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + }; + + mockAssets = { + upload: vi.fn().mockResolvedValue({ url: '/test.png' }), + getUrl: vi.fn().mockResolvedValue('/asset.png'), + delete: vi.fn().mockResolvedValue({}), + getDownloadUrl: vi.fn().mockResolvedValue({ url: '/download.xml', responseMessage: 'OK' }), + }; + + // Additional mock adapters needed for complete coverage + const mockUserPreferences = { + acceptLopd: vi.fn().mockResolvedValue({ success: true }), + getPreferences: vi.fn().mockResolvedValue({ userPreferences: {} }), + savePreferences: vi.fn().mockResolvedValue({ success: true }), + }; + + const mockLinkValidation = { + getSessionBrokenLinks: vi.fn().mockResolvedValue({ responseMessage: 'OK', brokenLinks: [] }), + getValidationStreamUrl: vi.fn().mockReturnValue('http://localhost/validate-stream'), + getPageBrokenLinks: vi.fn().mockResolvedValue({ brokenLinks: [] }), + getBlockBrokenLinks: vi.fn().mockResolvedValue({ brokenLinks: [] }), + getIdeviceBrokenLinks: vi.fn().mockResolvedValue({ brokenLinks: [] }), + }; + + const mockCloudStorage = { + getGoogleDriveLoginUrl: vi.fn().mockResolvedValue({ url: 'http://google.com/oauth' }), + getGoogleDriveFolders: vi.fn().mockResolvedValue({ folders: [] }), + uploadToGoogleDrive: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + getDropboxLoginUrl: vi.fn().mockResolvedValue({ url: 'http://dropbox.com/oauth' }), + getDropboxFolders: vi.fn().mockResolvedValue({ folders: [] }), + uploadToDropbox: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + }; + + const mockCollaboration = { + obtainBlockSync: vi.fn().mockResolvedValue({ responseMessage: 'OK', block: null }), + }; + + const mockExportAdapter = { + downloadExport: vi.fn().mockResolvedValue(new Blob(['export'])), + getPreviewUrl: vi.fn().mockResolvedValue({ responseMessage: 'OK', url: '/preview' }), + downloadIDevice: vi.fn().mockResolvedValue({ url: '', response: '', responseMessage: 'OK' }), + }; + + const mockPlatformIntegration = { + uploadElp: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + openElp: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + }; + + const mockSharing = { + getProject: vi.fn().mockResolvedValue({ responseMessage: 'OK', project: { id: 1 } }), + getSharingInfo: vi.fn().mockResolvedValue({ visibility: 'private', collaborators: [] }), + updateVisibility: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + addCollaborator: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + removeCollaborator: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + transferOwnership: vi.fn().mockResolvedValue({ responseMessage: 'OK' }), + }; + + // Store references for use in tests + window._mockAdapters = { + userPreferences: mockUserPreferences, + linkValidation: mockLinkValidation, + cloudStorage: mockCloudStorage, + collaboration: mockCollaboration, + exportAdapter: mockExportAdapter, + platformIntegration: mockPlatformIntegration, + sharing: mockSharing, }; window.eXeLearning = mockApp.eXeLearning; @@ -38,6 +171,20 @@ describe('ApiCallManager', () => { apiManager = new ApiCallManager(mockApp); mockFunc = apiManager.func; + + // Inject all mock adapters + apiManager.setAdapters({ + catalog: mockCatalog, + projectRepo: mockProjectRepo, + assets: mockAssets, + userPreferences: window._mockAdapters.userPreferences, + linkValidation: window._mockAdapters.linkValidation, + cloudStorage: window._mockAdapters.cloudStorage, + collaboration: window._mockAdapters.collaboration, + exportAdapter: window._mockAdapters.exportAdapter, + platformIntegration: window._mockAdapters.platformIntegration, + sharing: window._mockAdapters.sharing, + }); }); afterEach(() => { @@ -74,77 +221,57 @@ describe('ApiCallManager', () => { }); describe('getApiParameters', () => { - it('should call func.get with correct URL', async () => { + it('should call catalog adapter getApiParameters', async () => { await apiManager.getApiParameters(); - expect(mockFunc.get).toHaveBeenCalledWith(apiManager.apiUrlParameters); + expect(mockCatalog.getApiParameters).toHaveBeenCalled(); }); }); describe('getChangelogText', () => { - it('should call func.getText with version timestamp', async () => { - await apiManager.getChangelogText(); - expect(mockFunc.getText).toHaveBeenCalledWith(expect.stringContaining('version=123456')); + it('should call catalog adapter getChangelog', async () => { + const result = await apiManager.getChangelogText(); + expect(mockCatalog.getChangelog).toHaveBeenCalled(); + expect(result).toBe('changelog content'); }); }); describe('getThirdPartyCodeText / getLicensesList', () => { - it('should call func.getText with versioned paths', async () => { - global.eXeLearning.version = 'v9.9.9'; - + it('should call catalog adapter methods', async () => { await apiManager.getThirdPartyCodeText(); await apiManager.getLicensesList(); - expect(mockFunc.getText).toHaveBeenCalledWith( - 'http://localhost/exelearning/v9.9.9/libs/README' - ); - expect(mockFunc.getText).toHaveBeenCalledWith( - 'http://localhost/exelearning/v9.9.9/libs/LICENSES' - ); + expect(mockCatalog.getThirdPartyCode).toHaveBeenCalled(); + expect(mockCatalog.getLicensesList).toHaveBeenCalled(); }); }); describe('getUploadLimits', () => { - it('should call func.get with upload limits endpoint', async () => { - await apiManager.getUploadLimits(); - expect(mockFunc.get).toHaveBeenCalledWith( - 'http://localhost/exelearning/api/config/upload-limits' - ); + it('should call catalog adapter getUploadLimits', async () => { + const result = await apiManager.getUploadLimits(); + expect(mockCatalog.getUploadLimits).toHaveBeenCalled(); + expect(result).toEqual({ maxFileSize: 1024 }); }); }); describe('getTemplates', () => { - it('should call func.get with locale param', async () => { + it('should call catalog adapter getTemplates with locale', async () => { await apiManager.getTemplates('es'); - expect(mockFunc.get).toHaveBeenCalledWith( - 'http://localhost/exelearning/api/templates?locale=es' - ); + expect(mockCatalog.getTemplates).toHaveBeenCalledWith('es'); }); }); describe('getRecentUserOdeFiles', () => { - it('should fetch recent projects with auth header', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue([{ id: 'p1' }]), - }); - localStorage.setItem('authToken', 'recent-token'); + it('should call projectRepo adapter getRecent', async () => { + mockProjectRepo.getRecent.mockResolvedValue([{ id: 'p1' }]); const result = await apiManager.getRecentUserOdeFiles(); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/api/projects/user/recent'), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer recent-token', - }), - }) - ); + expect(mockProjectRepo.getRecent).toHaveBeenCalled(); expect(result).toEqual([{ id: 'p1' }]); - localStorage.removeItem('authToken'); }); - it('should return empty list on fetch error', async () => { - global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }); + it('should return empty list on error', async () => { + mockProjectRepo.getRecent.mockRejectedValue(new Error('Network error')); const result = await apiManager.getRecentUserOdeFiles(); @@ -170,15 +297,12 @@ describe('ApiCallManager', () => { }); describe('getIdevicesInstalled / getThemesInstalled', () => { - it('should call func.get with endpoints', async () => { - apiManager.endpoints.api_idevices_installed = { path: 'http://localhost/idevices' }; - apiManager.endpoints.api_themes_installed = { path: 'http://localhost/themes' }; - + it('should call catalog adapter methods', async () => { await apiManager.getIdevicesInstalled(); await apiManager.getThemesInstalled(); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/idevices'); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/themes'); + expect(mockCatalog.getIDevices).toHaveBeenCalled(); + expect(mockCatalog.getThemes).toHaveBeenCalled(); }); }); @@ -215,107 +339,67 @@ describe('ApiCallManager', () => { }); describe('getIdevicesBySessionId', () => { - it('should replace session id in endpoint path', async () => { - apiManager.endpoints.api_games_session_idevices = { - path: 'http://localhost/api/games/session/{odeSessionId}/idevices', - }; + it('should call catalog adapter getIdevicesBySessionId', async () => { + mockCatalog.getIdevicesBySessionId.mockResolvedValue([{ id: 1 }]); - await apiManager.getIdevicesBySessionId('sess-1'); + const result = await apiManager.getIdevicesBySessionId('sess-1'); - expect(mockFunc.get).toHaveBeenCalledWith( - 'http://localhost/api/games/session/sess-1/idevices' - ); + expect(mockCatalog.getIdevicesBySessionId).toHaveBeenCalledWith('sess-1'); + expect(result).toEqual([{ id: 1 }]); }); }); describe('upload/import helpers', () => { - it('should fall back to default URL when import route is missing', async () => { - apiManager.endpoints.api_odes_ode_local_elp_import_root_from_local = null; + it('should call projectRepo adapter importToRootFromLocal', async () => { const payload = { odeSessionId: 's1', odeFileName: 'f', odeFilePath: '/tmp' }; await apiManager.postImportElpToRootFromLocal(payload); - expect(mockFunc.post).toHaveBeenCalledWith( - 'http://localhost/exelearning/api/ode-management/odes/ode/import/local/root', - payload - ); + expect(mockProjectRepo.importToRootFromLocal).toHaveBeenCalledWith(payload); }); - it('should fall back and replace nav id for import child', async () => { - apiManager.endpoints.api_nav_structures_import_elp_child = null; + it('should call projectRepo adapter importAsChild with navId', async () => { const payload = { odeSessionId: 's1' }; await apiManager.postImportElpAsChildFromLocal('nav-123', payload); - expect(mockFunc.post).toHaveBeenCalledWith( - 'http://localhost/exelearning/api/nav-structure-management/nav-structures/nav-123/import-elp', - payload - ); + expect(mockProjectRepo.importAsChild).toHaveBeenCalledWith('nav-123', payload); }); }); describe('theme and idevice helpers', () => { - it('should replace theme dir in edit endpoint', async () => { - apiManager.endpoints.api_themes_edit = { path: 'http://localhost/themes/{themeDirName}' }; - + it('should call catalog adapter updateTheme', async () => { await apiManager.putEditTheme('theme-1', { name: 'Theme' }); - expect(mockFunc.put).toHaveBeenCalledWith( - 'http://localhost/themes/theme-1', - { name: 'Theme' } - ); + expect(mockCatalog.updateTheme).toHaveBeenCalledWith('theme-1', { name: 'Theme' }); }); - it('should replace params in theme zip download', async () => { - apiManager.endpoints.api_themes_download = { - path: 'http://localhost/themes/{odeSessionId}/{themeDirName}', - }; - + it('should call catalog adapter getThemeZip', async () => { await apiManager.getThemeZip('session-1', 'theme-1'); - expect(mockFunc.get).toHaveBeenCalledWith( - 'http://localhost/themes/session-1/theme-1' - ); + expect(mockCatalog.getThemeZip).toHaveBeenCalledWith('session-1', 'theme-1'); }); - it('should replace params in idevice zip download', async () => { - apiManager.endpoints.api_idevices_installed_download = { - path: 'http://localhost/idevices/{odeSessionId}/{ideviceDirName}', - }; - + it('should call catalog adapter getIdeviceZip', async () => { await apiManager.getIdeviceInstalledZip('session-1', 'idevice-1'); - expect(mockFunc.get).toHaveBeenCalledWith( - 'http://localhost/idevices/session-1/idevice-1' - ); + expect(mockCatalog.getIdeviceZip).toHaveBeenCalledWith('session-1', 'idevice-1'); }); }); describe('getUserOdeFiles', () => { - it('should fetch user projects with auth header', async () => { - const mockProjects = { odeFiles: { odeFilesSync: [{ id: 1 }] } }; - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue(mockProjects), - }); - localStorage.setItem('authToken', 'test-token'); + it('should call projectRepo adapter list and wrap in expected format', async () => { + const mockProjects = [{ id: 1 }, { id: 2 }]; + mockProjectRepo.list.mockResolvedValue(mockProjects); const result = await apiManager.getUserOdeFiles(); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/api/projects/user/list'), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer test-token', - }), - }) - ); - expect(result).toEqual(mockProjects); - localStorage.removeItem('authToken'); + expect(mockProjectRepo.list).toHaveBeenCalled(); + expect(result).toEqual({ odeFiles: { odeFilesSync: mockProjects } }); }); - it('should return empty list on fetch error', async () => { - global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }); + it('should return empty list on adapter error', async () => { + mockProjectRepo.list.mockRejectedValue(new Error('Database error')); const result = await apiManager.getUserOdeFiles(); expect(result.odeFiles.odeFilesSync).toEqual([]); }); @@ -467,13 +551,13 @@ describe('ApiCallManager', () => { }); describe('postOdeSave', () => { - it('should call func.post with correct endpoint', async () => { - apiManager.endpoints.api_odes_ode_save_manual = { path: 'http://localhost/save' }; + it('should call projectRepo adapter save', async () => { + window.eXeLearning.odeSessionId = 'sess-1'; const params = { data: 'test' }; - + await apiManager.postOdeSave(params); - - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/save', params); + + expect(mockProjectRepo.save).toHaveBeenCalledWith('sess-1', params); }); }); @@ -1034,61 +1118,30 @@ describe('ApiCallManager', () => { }); describe('getProject', () => { - it('should build correct URL for numeric ID', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ id: 123 }), - }); - + it('should call sharing adapter getProject for numeric ID', async () => { await apiManager.getProject(123); - expect(global.fetch).toHaveBeenCalledWith( - 'http://localhost/exelearning/api/projects/123/sharing', - expect.any(Object) - ); + expect(window._mockAdapters.sharing.getProject).toHaveBeenCalledWith(123); }); - it('should build correct URL for UUID', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ id: 'uuid-123' }), - }); - + it('should call sharing adapter getProject for UUID', async () => { await apiManager.getProject('uuid-123'); - expect(global.fetch).toHaveBeenCalledWith( - 'http://localhost/exelearning/api/projects/uuid/uuid-123/sharing', - expect.any(Object) - ); + expect(window._mockAdapters.sharing.getProject).toHaveBeenCalledWith('uuid-123'); }); }); describe('getOdeExportDownload', () => { - it('should post structure for Yjs sessions', async () => { - apiManager.endpoints.api_ode_export_download = { - path: 'http://localhost/export/{odeSessionId}/{exportType}', - }; - vi.spyOn(apiManager, 'buildStructureFromYjs').mockReturnValue({ pages: [] }); - + it('should call exportAdapter downloadExport', async () => { await apiManager.getOdeExportDownload('yjs-123', 'html5'); - expect(mockFunc.post).toHaveBeenCalledWith( - 'http://localhost/export/yjs-123/html5', - { structure: { pages: [] } } - ); + expect(window._mockAdapters.exportAdapter.downloadExport).toHaveBeenCalledWith('yjs-123', 'html5'); }); - it('should fallback to get when structure is unavailable', async () => { - apiManager.endpoints.api_ode_export_download = { - path: 'http://localhost/export/{odeSessionId}/{exportType}', - }; - vi.spyOn(apiManager, 'buildStructureFromYjs').mockReturnValue(null); - - await apiManager.getOdeExportDownload('yjs-456', 'html5'); + it('should handle different export types', async () => { + await apiManager.getOdeExportDownload('sess-456', 'scorm2004'); - expect(mockFunc.get).toHaveBeenCalledWith( - 'http://localhost/export/yjs-456/html5' - ); + expect(window._mockAdapters.exportAdapter.downloadExport).toHaveBeenCalledWith('sess-456', 'scorm2004'); }); }); @@ -1151,41 +1204,28 @@ describe('ApiCallManager', () => { }); describe('getOdeIdevicesDownload', () => { - it('should build download response and call getText', async () => { - apiManager.endpoints.api_idevices_download_ode_components = { - path: 'http://localhost/idevices/{odeSessionId}/{odeBlockId}/{odeIdeviceId}', - }; - mockFunc.getText.mockResolvedValue('payload'); + it('should call exportAdapter downloadIDevice', async () => { + await apiManager.getOdeIdevicesDownload('s1', 'b1', 'i1'); - const result = await apiManager.getOdeIdevicesDownload('s1', 'b1', 'i1'); - - expect(mockFunc.getText).toHaveBeenCalledWith( - 'http://localhost/idevices/s1/b1/i1' - ); - expect(result.url).toBe('http://localhost/idevices/s1/b1/i1'); - expect(result.response).toBe('payload'); + expect(window._mockAdapters.exportAdapter.downloadIDevice).toHaveBeenCalledWith('s1', 'b1', 'i1'); }); }); describe('getFileResourcesForceDownload', () => { - it('should return url with resource param', async () => { - apiManager.endpoints.api_idevices_force_download_file_resources = { - path: 'http://localhost/resource', - }; + it('should call assets adapter getDownloadUrl', async () => { + await apiManager.getFileResourcesForceDownload('file.xml'); - const result = await apiManager.getFileResourcesForceDownload('file.xml'); - - expect(result.url).toBe('http://localhost/resource?resource=file.xml'); + expect(mockAssets.getDownloadUrl).toHaveBeenCalledWith('file.xml'); }); }); describe('postOdeAutosave', () => { - it('should call post for autosave', async () => { - apiManager.endpoints.api_odes_ode_save_auto = { path: 'http://localhost/autosave' }; + it('should call projectRepo adapter autoSave', async () => { + window.eXeLearning.odeSessionId = 'sess-1'; await apiManager.postOdeAutosave({ data: 'autosave' }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/autosave', { data: 'autosave' }); + expect(mockProjectRepo.autoSave).toHaveBeenCalledWith('sess-1', { data: 'autosave' }); }); }); @@ -1223,74 +1263,30 @@ describe('ApiCallManager', () => { }); describe('project sharing api', () => { - it('should return error on visibility update failure', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - json: vi.fn().mockResolvedValue({ message: 'bad' }), - }); - - const result = await apiManager.updateProjectVisibility(1, 'public'); + it('should call sharing adapter updateVisibility', async () => { + await apiManager.updateProjectVisibility(1, 'public'); - expect(result.responseMessage).toBe('ERROR'); + expect(window._mockAdapters.sharing.updateVisibility).toHaveBeenCalledWith(1, 'public'); }); - it('should map collaborator errors', async () => { - global.fetch = vi.fn() - .mockResolvedValueOnce({ - ok: false, - status: 404, - json: vi.fn().mockResolvedValue({ message: 'not found' }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 400, - json: vi.fn().mockResolvedValue({ message: 'already collaborator' }), - }); - - const notFound = await apiManager.addProjectCollaborator(1, 'a@b.com'); - const already = await apiManager.addProjectCollaborator(1, 'a@b.com'); + it('should call sharing adapter addCollaborator', async () => { + await apiManager.addProjectCollaborator(1, 'a@b.com', 'editor'); - expect(notFound.responseMessage).toBe('USER_NOT_FOUND'); - expect(already.responseMessage).toBe('ALREADY_COLLABORATOR'); + expect(window._mockAdapters.sharing.addCollaborator).toHaveBeenCalledWith(1, 'a@b.com', 'editor'); }); - it('should handle collaborator removal and transfer', async () => { - global.fetch = vi.fn() - .mockResolvedValueOnce({ - ok: true, - json: vi.fn().mockResolvedValue({ ok: true }), - }) - .mockResolvedValueOnce({ - ok: true, - json: vi.fn().mockResolvedValue({ ok: true }), - }); - - const removeResult = await apiManager.removeProjectCollaborator(1, 2); - const transferResult = await apiManager.transferProjectOwnership(1, 99); + it('should call sharing adapter removeCollaborator and transferOwnership', async () => { + await apiManager.removeProjectCollaborator(1, 2); + await apiManager.transferProjectOwnership(1, 99); - expect(removeResult).toEqual({ ok: true }); - expect(transferResult).toEqual({ ok: true }); + expect(window._mockAdapters.sharing.removeCollaborator).toHaveBeenCalledWith(1, 2); + expect(window._mockAdapters.sharing.transferOwnership).toHaveBeenCalledWith(1, 99); }); }); - describe('api wrapper calls', () => { - it('should call legacy session endpoints', async () => { - apiManager.endpoints.check_current_users_ode_session_id = { path: 'http://localhost/join' }; - apiManager.endpoints.api_odes_ode_elp_open = { path: 'http://localhost/open' }; - apiManager.endpoints.api_odes_ode_local_large_elp_open = { path: 'http://localhost/large' }; - apiManager.endpoints.api_odes_ode_local_elp_open = { path: 'http://localhost/local' }; - apiManager.endpoints.api_odes_ode_local_xml_properties_open = { path: 'http://localhost/xml' }; - apiManager.endpoints.api_odes_ode_local_elp_import_root = { path: 'http://localhost/import-root' }; - apiManager.endpoints.api_odes_ode_local_idevices_open = { path: 'http://localhost/idevices' }; - apiManager.endpoints.api_odes_ode_multiple_local_elp_open = { path: 'http://localhost/multi' }; - apiManager.endpoints.api_odes_remove_ode_file = { path: 'http://localhost/remove' }; - apiManager.endpoints.api_odes_remove_date_ode_files = { path: 'http://localhost/remove-date' }; - apiManager.endpoints.api_odes_check_before_leave_ode_session = { path: 'http://localhost/check' }; - apiManager.endpoints.api_odes_clean_init_autosave_elp = { path: 'http://localhost/clean' }; - apiManager.endpoints.api_odes_ode_session_close = { path: 'http://localhost/close' }; - - await apiManager.postJoinCurrentOdeSessionId({ id: 1 }); + describe('api wrapper calls via adapters', () => { + it('should call projectRepo adapter for session operations', async () => { + await apiManager.postJoinCurrentOdeSessionId({ odeSessionId: 'sess-1' }); await apiManager.postSelectedOdeFile({ name: 'file' }); await apiManager.postLocalLargeOdeFile({ data: 'big' }); await apiManager.postLocalOdeFile({ data: 'small' }); @@ -1304,34 +1300,23 @@ describe('ApiCallManager', () => { await apiManager.postCleanAutosavesByUser({ id: 1 }); await apiManager.postCloseSession({ id: 1 }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/join', { id: 1 }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/open', { name: 'file' }); - expect(mockFunc.fileSendPost).toHaveBeenCalledWith('http://localhost/large', { data: 'big' }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/local', { data: 'small' }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/xml', { data: 'xml' }); - expect(mockFunc.fileSendPost).toHaveBeenCalledWith('http://localhost/import-root', { data: 'root' }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/idevices', { data: 'components' }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/multi', { data: 'multi' }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/remove', { id: 1 }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/remove-date', { from: '2020' }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/check', { id: 1 }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/clean', { id: 1 }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/close', { id: 1 }); - }); - - it('should call theme, idevice, and preference endpoints', async () => { - apiManager.endpoints.api_themes_upload = { path: 'http://localhost/theme/upload' }; - apiManager.endpoints.api_ode_theme_import = { path: 'http://localhost/theme/import' }; - apiManager.endpoints.api_themes_installed_delete = { path: 'http://localhost/theme/delete' }; - apiManager.endpoints.api_themes_new = { path: 'http://localhost/theme/new' }; - apiManager.endpoints.api_idevices_upload = { path: 'http://localhost/idevices/upload' }; - apiManager.endpoints.api_idevices_installed_delete = { path: 'http://localhost/idevices/delete' }; - apiManager.endpoints.api_user_set_lopd_accepted = { path: 'http://localhost/lopd' }; - apiManager.endpoints.api_user_preferences_get = { path: 'http://localhost/prefs' }; - apiManager.endpoints.api_user_preferences_save = { path: 'http://localhost/prefs/save' }; - + expect(mockProjectRepo.joinSession).toHaveBeenCalledWith('sess-1'); + expect(mockProjectRepo.openFile).toHaveBeenCalledWith({ name: 'file' }); + expect(mockProjectRepo.openLargeLocalFile).toHaveBeenCalledWith({ data: 'big' }); + expect(mockProjectRepo.openLocalFile).toHaveBeenCalledWith({ data: 'small' }); + expect(mockProjectRepo.getLocalProperties).toHaveBeenCalledWith({ data: 'xml' }); + expect(mockProjectRepo.importToRoot).toHaveBeenCalledWith({ data: 'root' }); + expect(mockProjectRepo.getLocalComponents).toHaveBeenCalledWith({ data: 'components' }); + expect(mockProjectRepo.openMultipleLocalFiles).toHaveBeenCalledWith({ data: 'multi' }); + expect(mockProjectRepo.delete).toHaveBeenCalledWith({ id: 1 }); + expect(mockProjectRepo.deleteByDate).toHaveBeenCalledWith({ from: '2020' }); + expect(mockProjectRepo.checkCurrentUsers).toHaveBeenCalledWith({ id: 1 }); + expect(mockProjectRepo.cleanAutosaves).toHaveBeenCalledWith({ id: 1 }); + expect(mockProjectRepo.closeSession).toHaveBeenCalledWith({ id: 1 }); + }); + + it('should call catalog and userPreferences adapters', async () => { await apiManager.postUploadTheme({ data: 'theme' }); - // postOdeImportTheme uses fetch directly (not mockFunc), tested separately await apiManager.deleteTheme({ id: 1 }); await apiManager.postNewTheme({ name: 'new' }); await apiManager.postUploadIdevice({ data: 'idevice' }); @@ -1340,32 +1325,64 @@ describe('ApiCallManager', () => { await apiManager.getUserPreferences(); await apiManager.putSaveUserPreferences({ mode: 'dark' }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/theme/upload', { data: 'theme' }); - expect(mockFunc.delete).toHaveBeenCalledWith('http://localhost/theme/delete', { id: 1 }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/theme/new', { name: 'new' }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/idevices/upload', { data: 'idevice' }); - expect(mockFunc.delete).toHaveBeenCalledWith('http://localhost/idevices/delete', { id: 2 }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/lopd'); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/prefs'); - expect(mockFunc.put).toHaveBeenCalledWith('http://localhost/prefs/save', { mode: 'dark' }); + expect(mockCatalog.uploadTheme).toHaveBeenCalledWith({ data: 'theme' }); + expect(mockCatalog.deleteTheme).toHaveBeenCalledWith({ id: 1 }); + expect(mockCatalog.createTheme).toHaveBeenCalledWith({ name: 'new' }); + expect(mockCatalog.uploadIdevice).toHaveBeenCalledWith({ data: 'idevice' }); + expect(mockCatalog.deleteIdevice).toHaveBeenCalledWith({ id: 2 }); + expect(window._mockAdapters.userPreferences.acceptLopd).toHaveBeenCalled(); + expect(window._mockAdapters.userPreferences.getPreferences).toHaveBeenCalled(); + expect(window._mockAdapters.userPreferences.savePreferences).toHaveBeenCalledWith({ mode: 'dark' }); }); + }); - it('should call structure and diagnostics endpoints', async () => { - apiManager.endpoints.api_odes_last_updated = { path: 'http://localhost/last/{odeId}' }; - apiManager.endpoints.api_odes_current_users = { - path: 'http://localhost/users/{odeId}/{odeVersionId}/{odeSessionId}', - }; - apiManager.endpoints.api_nav_structures_nav_structure_get = { - path: 'http://localhost/structure/{odeVersionId}/{odeSessionId}', - }; - apiManager.endpoints.api_odes_session_get_broken_links = { path: 'http://localhost/broken/session' }; - apiManager.endpoints.api_odes_pag_get_broken_links = { path: 'http://localhost/broken/page/{odePageId}' }; - apiManager.endpoints.api_odes_block_get_broken_links = { path: 'http://localhost/broken/block/{odeBlockId}' }; - apiManager.endpoints.api_odes_idevice_get_broken_links = { path: 'http://localhost/broken/idevice/{odeIdeviceId}' }; - apiManager.endpoints.api_odes_properties_get = { path: 'http://localhost/properties/{odeSessionId}' }; - apiManager.endpoints.api_odes_properties_save = { path: 'http://localhost/properties/save' }; - apiManager.endpoints.api_odes_session_get_used_files = { path: 'http://localhost/used-files' }; + describe('getUserPreferences', () => { + it('should call userPreferences adapter getPreferences', async () => { + const result = await apiManager.getUserPreferences(); + + expect(window._mockAdapters.userPreferences.getPreferences).toHaveBeenCalled(); + expect(result).toEqual({ userPreferences: {} }); + }); + + it('should return adapter response', async () => { + window._mockAdapters.userPreferences.getPreferences.mockResolvedValueOnce({ + userPreferences: { + locale: { value: 'es' }, + advancedMode: { value: 'true' }, + }, + }); + const result = await apiManager.getUserPreferences(); + + expect(result.userPreferences.locale.value).toBe('es'); + expect(result.userPreferences.advancedMode.value).toBe('true'); + }); + }); + + describe('putSaveUserPreferences', () => { + it('should call userPreferences adapter savePreferences', async () => { + const result = await apiManager.putSaveUserPreferences({ locale: 'es', advancedMode: 'false' }); + + expect(window._mockAdapters.userPreferences.savePreferences).toHaveBeenCalledWith({ + locale: 'es', + advancedMode: 'false', + }); + expect(result.success).toBe(true); + }); + + it('should return adapter error response', async () => { + window._mockAdapters.userPreferences.savePreferences.mockResolvedValueOnce({ + success: false, + error: 'Storage full', + }); + + const result = await apiManager.putSaveUserPreferences({ locale: 'es' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Storage full'); + }); + + it('should call structure and diagnostics endpoints via adapters', async () => { await apiManager.getOdeLastUpdated('ode-1'); await apiManager.getOdeConcurrentUsers('ode-1', 'v1', 's1'); await apiManager.getOdeStructure('v1', 's1'); @@ -1375,18 +1392,16 @@ describe('ApiCallManager', () => { await apiManager.getOdeIdeviceBrokenLinks('idev-1'); await apiManager.getOdeProperties('s1'); await apiManager.putSaveOdeProperties({ id: 1 }); - await apiManager.getOdeSessionUsedFiles({ id: 1 }); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/last/ode-1'); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/users/ode-1/v1/s1', null, false); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/structure/v1/s1'); - expect(mockFunc.postJson).toHaveBeenCalledWith('http://localhost/broken/session', { id: 1 }); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/broken/page/page-1'); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/broken/block/block-1'); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/broken/idevice/idev-1'); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/properties/s1'); - expect(mockFunc.put).toHaveBeenCalledWith('http://localhost/properties/save', { id: 1 }); - expect(mockFunc.postJson).toHaveBeenCalledWith('http://localhost/used-files', { id: 1 }); + expect(mockProjectRepo.getLastUpdated).toHaveBeenCalledWith('ode-1'); + expect(mockProjectRepo.getConcurrentUsers).toHaveBeenCalledWith('ode-1', 'v1', 's1'); + expect(mockProjectRepo.getStructure).toHaveBeenCalledWith('v1', 's1'); + expect(window._mockAdapters.linkValidation.getSessionBrokenLinks).toHaveBeenCalledWith({ id: 1 }); + expect(window._mockAdapters.linkValidation.getPageBrokenLinks).toHaveBeenCalledWith('page-1'); + expect(window._mockAdapters.linkValidation.getBlockBrokenLinks).toHaveBeenCalledWith('block-1'); + expect(window._mockAdapters.linkValidation.getIdeviceBrokenLinks).toHaveBeenCalledWith('idev-1'); + expect(mockProjectRepo.getProperties).toHaveBeenCalledWith('s1'); + expect(mockProjectRepo.saveProperties).toHaveBeenCalledWith({ id: 1 }); }); it('should call page, block, and file endpoints', async () => { @@ -1423,16 +1438,7 @@ describe('ApiCallManager', () => { expect(mockFunc.fileSendPost).toHaveBeenCalledWith('http://localhost/file/large', { file: 'b' }); }); - it('should call translation and cloud endpoints', async () => { - apiManager.endpoints.api_translations_lists = { path: 'http://localhost/i18n' }; - apiManager.endpoints.api_translations_list_by_locale = { path: 'http://localhost/i18n/{locale}' }; - apiManager.endpoints.api_google_oauth_login_url_get = { path: 'http://localhost/google/login' }; - apiManager.endpoints.api_google_drive_folders_list = { path: 'http://localhost/google/folders' }; - apiManager.endpoints.api_google_drive_file_upload = { path: 'http://localhost/google/upload' }; - apiManager.endpoints.api_dropbox_oauth_login_url_get = { path: 'http://localhost/dropbox/login' }; - apiManager.endpoints.api_dropbox_folders_list = { path: 'http://localhost/dropbox/folders' }; - apiManager.endpoints.api_dropbox_file_upload = { path: 'http://localhost/dropbox/upload' }; - + it('should call translation and cloud endpoints via adapters', async () => { await apiManager.getTranslationsAll(); await apiManager.getTranslations('es'); await apiManager.getUrlLoginGoogleDrive(); @@ -1442,122 +1448,87 @@ describe('ApiCallManager', () => { await apiManager.getFoldersDropbox(); await apiManager.uploadFileDropbox({ file: 'b' }); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/i18n'); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/i18n/es'); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/google/login'); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/google/folders'); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/google/upload', { file: 'a' }); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/dropbox/login'); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/dropbox/folders'); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/dropbox/upload', { file: 'b' }); + expect(mockCatalog.getLocales).toHaveBeenCalled(); + expect(mockCatalog.getTranslations).toHaveBeenCalledWith('es'); + expect(window._mockAdapters.cloudStorage.getGoogleDriveLoginUrl).toHaveBeenCalled(); + expect(window._mockAdapters.cloudStorage.getGoogleDriveFolders).toHaveBeenCalled(); + expect(window._mockAdapters.cloudStorage.uploadToGoogleDrive).toHaveBeenCalledWith({ file: 'a' }); + expect(window._mockAdapters.cloudStorage.getDropboxLoginUrl).toHaveBeenCalled(); + expect(window._mockAdapters.cloudStorage.getDropboxFolders).toHaveBeenCalled(); + expect(window._mockAdapters.cloudStorage.uploadToDropbox).toHaveBeenCalledWith({ file: 'b' }); }); - it('should call component html endpoints', async () => { - apiManager.endpoints.api_idevices_html_template_get = { - path: 'http://localhost/html/{odeComponentsSyncId}', - }; - apiManager.endpoints.api_idevices_html_view_get = { - path: 'http://localhost/html/view/{odeComponentsSyncId}', - }; - + it('should call component html endpoints via catalog adapter', async () => { await apiManager.getComponentHtmlTemplate('comp-1'); await apiManager.getSaveHtmlView('comp-2'); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/html/comp-1'); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/html/view/{odeComponentsSyncId}'); + expect(mockCatalog.getComponentHtmlTemplate).toHaveBeenCalledWith('comp-1'); + expect(mockCatalog.getSaveHtmlView).toHaveBeenCalledWith('comp-2'); }); - it('should call idevice save and reorder endpoints', async () => { + it('should call idevice save/reorder and preview via adapters', async () => { apiManager.endpoints.api_idevices_idevice_data_save = { path: 'http://localhost/idevice/save' }; apiManager.endpoints.api_idevices_idevice_reorder = { path: 'http://localhost/idevice/reorder' }; - apiManager.endpoints.api_ode_export_preview = { path: 'http://localhost/preview/{odeSessionId}' }; await apiManager.putSaveIdevice({ id: 1 }); await apiManager.putReorderIdevice({ id: 1 }); await apiManager.getOdePreviewUrl('sess-1'); + // idevice save/reorder still use func for now expect(mockFunc.put).toHaveBeenCalledWith('http://localhost/idevice/save', { id: 1 }); expect(mockFunc.put).toHaveBeenCalledWith('http://localhost/idevice/reorder', { id: 1 }); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/preview/sess-1'); + // preview uses export adapter + expect(window._mockAdapters.exportAdapter.getPreviewUrl).toHaveBeenCalledWith('sess-1'); }); - it('should call block sync endpoint', async () => { - apiManager.endpoints.get_current_block_update = { path: 'http://localhost/block/sync' }; - + it('should call block sync via collaboration adapter', async () => { await apiManager.postObtainOdeBlockSync({ id: 1 }); - expect(mockFunc.post).toHaveBeenCalledWith('http://localhost/block/sync', { id: 1 }); + expect(window._mockAdapters.collaboration.obtainBlockSync).toHaveBeenCalledWith({ id: 1 }); }); - it('should call export download shortcut', async () => { - apiManager.endpoints.api_ode_export_download = { - path: 'http://localhost/export/{odeSessionId}/{exportType}', - }; + it('should call export download via adapter', async () => { global.eXeLearning.extension = 'html5'; await apiManager.getOdeDownload('sess-1'); - expect(mockFunc.get).toHaveBeenCalledWith('http://localhost/export/sess-1/html5'); + expect(window._mockAdapters.exportAdapter.downloadExport).toHaveBeenCalledWith('sess-1', 'html5'); }); }); describe('postOdeImportTheme', () => { - it('should return error when themeZip is not provided', async () => { - const result = await apiManager.postOdeImportTheme({ themeDirname: 'test-theme' }); - expect(result.responseMessage).toBe('ERROR'); - expect(result.error).toContain('Theme import requires the theme files'); - }); - - it('should return error when themeDirname is not provided', async () => { - const mockBlob = new Blob(['test'], { type: 'application/zip' }); - const result = await apiManager.postOdeImportTheme({ themeZip: mockBlob }); - expect(result.responseMessage).toBe('ERROR'); - expect(result.error).toContain('Theme directory name is required'); - }); - - it('should successfully upload theme with FormData', async () => { + it('should call catalog adapter importTheme method', async () => { const mockBlob = new Blob(['test'], { type: 'application/zip' }); - const mockResponse = { responseMessage: 'OK', themes: { themes: [] } }; - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); const result = await apiManager.postOdeImportTheme({ themeDirname: 'test-theme', themeZip: mockBlob, }); + expect(mockCatalog.importTheme).toHaveBeenCalledWith({ + themeDirname: 'test-theme', + themeZip: mockBlob, + }); expect(result.responseMessage).toBe('OK'); - expect(global.fetch).toHaveBeenCalled(); - const fetchCall = global.fetch.mock.calls[0]; - expect(fetchCall[0]).toBe('http://localhost/exelearning/api/themes/import'); - expect(fetchCall[1].method).toBe('POST'); - expect(fetchCall[1].body).toBeInstanceOf(FormData); }); - it('should handle fetch errors gracefully', async () => { + it('should handle adapter errors gracefully', async () => { const mockBlob = new Blob(['test'], { type: 'application/zip' }); - global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + mockCatalog.importTheme.mockRejectedValueOnce(new Error('Network error')); - const result = await apiManager.postOdeImportTheme({ + await expect(apiManager.postOdeImportTheme({ themeDirname: 'test-theme', themeZip: mockBlob, - }); - - expect(result.responseMessage).toBe('ERROR'); - expect(result.error).toBe('Network error'); + })).rejects.toThrow('Network error'); }); - it('should handle HTTP error responses', async () => { + it('should return error response from adapter', async () => { const mockBlob = new Blob(['test'], { type: 'application/zip' }); - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - json: () => Promise.resolve({ error: 'Invalid theme' }), + mockCatalog.importTheme.mockResolvedValueOnce({ + responseMessage: 'ERROR', + error: 'Invalid theme', }); const result = await apiManager.postOdeImportTheme({ @@ -1569,4 +1540,209 @@ describe('ApiCallManager', () => { expect(result.error).toBe('Invalid theme'); }); }); + + describe('Yjs-based content extraction', () => { + let mockStructureBinding; + + beforeEach(() => { + // Set up mock structureBinding for Yjs + // Note: _ymap contains raw content before URL resolution (asset:// URLs) + // while htmlContent is already resolved (blob:// URLs) + const mockYmap = { + get: vi.fn((key) => { + if (key === 'htmlContent') { + return { + toString: () => '

Test content with link and

', + }; + } + if (key === 'jsonProperties') { + return '{}'; + } + return null; + }), + }; + + mockStructureBinding = { + getPages: vi.fn().mockReturnValue([ + { id: 'page-1', pageName: 'Test Page' }, + ]), + getBlocks: vi.fn().mockReturnValue([ + { id: 'block-1', blockName: 'Test Block' }, + ]), + getComponents: vi.fn().mockReturnValue([ + { + // htmlContent is resolved (blob URLs) - used by link extraction + htmlContent: '

Test content with link and

', + ideviceType: 'textIdevice', + order: 1, + // _ymap contains raw content (asset:// URLs) - used by resource report + _ymap: mockYmap, + }, + ]), + }; + }); + + describe('getOdeSessionUsedFiles', () => { + it('should always use Yjs to extract assets', async () => { + // Set up Yjs active state + window.eXeLearning.app.project = { + _yjsEnabled: true, + _yjsBridge: { + structureBinding: mockStructureBinding, + assetManager: null, + }, + }; + + const result = await apiManager.getOdeSessionUsedFiles({ id: 1 }); + + expect(mockStructureBinding.getPages).toHaveBeenCalled(); + expect(result.responseMessage).toBe('OK'); + expect(result.usedFiles).toBeDefined(); + expect(Array.isArray(result.usedFiles)).toBe(true); + // Should find the asset:// URL in the htmlContent + expect(result.usedFiles.length).toBeGreaterThan(0); + expect(result.usedFiles[0].usedFilesPath).toContain('asset://'); + }); + + it('should return empty array with OK response when structureBinding is not available', async () => { + window.eXeLearning.app.project = { + _yjsEnabled: true, + _yjsBridge: { + structureBinding: null, + }, + }; + + const result = await apiManager.getOdeSessionUsedFiles({ id: 1 }); + + expect(result.responseMessage).toBe('OK'); + expect(result.usedFiles).toEqual([]); + }); + + it('should use AssetManager.getAllAssetsMetadata and include usage location', async () => { + // Use UUID-like hex values that match the asset regex pattern: /asset:\/\/([a-f0-9-]+)/gi + const assetId1 = 'aabbccdd-1111-2222-3333-444455556666'; + const assetId2 = 'eeff0011-5555-6666-7777-888899990000'; + + // Create a mock with content that includes asset references + const mockStructureBindingWithAssets = { + getPages: vi.fn().mockReturnValue([{ id: 'page-1', pageName: 'Test Page' }]), + getBlocks: vi.fn().mockReturnValue([{ id: 'block-1', blockName: 'Test Block' }]), + getComponents: vi.fn().mockReturnValue([{ + ideviceType: 'textIdevice', + order: 1, + _ymap: { + get: (key) => key === 'htmlContent' ? `` : null, + }, + }]), + }; + + const mockAssetManager = { + getAllAssetsMetadata: vi.fn().mockReturnValue([ + { id: assetId1, name: 'test.jpg', size: 1024 }, + { id: assetId2, name: 'test2.png', size: 2048 }, + ]), + getAsset: vi.fn().mockResolvedValue(null), + }; + + window.eXeLearning.app.project = { + _yjsEnabled: true, + _yjsBridge: { + structureBinding: mockStructureBindingWithAssets, + assetManager: mockAssetManager, + }, + }; + + const result = await apiManager.getOdeSessionUsedFiles({ id: 1 }); + + expect(result.responseMessage).toBe('OK'); + expect(mockAssetManager.getAllAssetsMetadata).toHaveBeenCalled(); + expect(result.usedFiles.length).toBe(2); + + // First asset (assetId1) should have usage location since it's in content + const asset1File = result.usedFiles.find(f => f.usedFilesPath === `asset://${assetId1}`); + expect(asset1File.usedFiles).toBe('test.jpg'); + expect(asset1File.pageNamesUsedFiles).toBe('Test Page'); + expect(asset1File.blockNamesUsedFiles).toBe('Test Block'); + expect(asset1File.typeComponentSyncUsedFiles).toBe('text'); + + // Second asset (assetId2) is not in content, so location should be '-' + const asset2File = result.usedFiles.find(f => f.usedFilesPath === `asset://${assetId2}`); + expect(asset2File.usedFiles).toBe('test2.png'); + expect(asset2File.pageNamesUsedFiles).toBe('-'); + }); + }); + + describe('extractLinksForValidation', () => { + it('should always extract links from Yjs', async () => { + window.eXeLearning.app.project = { + _yjsEnabled: true, + _yjsBridge: { + structureBinding: mockStructureBinding, + }, + }; + + const result = await apiManager.extractLinksForValidation({ odeSessionId: 's1', idevices: [] }); + + expect(mockStructureBinding.getPages).toHaveBeenCalled(); + expect(result.links).toBeDefined(); + expect(Array.isArray(result.links)).toBe(true); + // Should find the href URL in the htmlContent + expect(result.links.length).toBeGreaterThan(0); + expect(result.links[0].url).toBe('https://example.com'); + }); + + it('should skip internal anchors and asset URLs', async () => { + mockStructureBinding.getComponents.mockReturnValue([ + { + htmlContent: 'AnchorAssetValid', + ideviceType: 'textIdevice', + order: 1, + }, + ]); + + window.eXeLearning.app.project = { + _yjsEnabled: true, + _yjsBridge: { + structureBinding: mockStructureBinding, + }, + }; + + const result = await apiManager.extractLinksForValidation({ odeSessionId: 's1', idevices: [] }); + + // Should only find the valid https link, not anchor or asset + expect(result.links.length).toBe(1); + expect(result.links[0].url).toBe('https://valid.com'); + }); + + it('should return empty array when structureBinding is not available', async () => { + window.eXeLearning.app.project = { + _yjsEnabled: true, + _yjsBridge: { + structureBinding: null, + }, + }; + + const result = await apiManager.extractLinksForValidation({ odeSessionId: 's1', idevices: [] }); + + expect(result.links).toEqual([]); + expect(result.totalLinks).toBe(0); + }); + }); + + describe('_formatFileSize', () => { + it('should format bytes correctly', () => { + expect(apiManager._formatFileSize(0)).toBe(''); + expect(apiManager._formatFileSize(512)).toBe('512.0 B'); + expect(apiManager._formatFileSize(1024)).toBe('1.0 KB'); + expect(apiManager._formatFileSize(1536)).toBe('1.5 KB'); + expect(apiManager._formatFileSize(1048576)).toBe('1.0 MB'); + expect(apiManager._formatFileSize(1073741824)).toBe('1.0 GB'); + }); + + it('should return empty string for null/undefined', () => { + expect(apiManager._formatFileSize(null)).toBe(''); + expect(apiManager._formatFileSize(undefined)).toBe(''); + }); + }); + }); }); diff --git a/public/app/workarea/idevices/idevice.js b/public/app/workarea/idevices/idevice.js index c0c635e4a..81b2a312d 100644 --- a/public/app/workarea/idevices/idevice.js +++ b/public/app/workarea/idevices/idevice.js @@ -48,8 +48,10 @@ export default class Idevice { /** * config.xml translatable params + * Note: 'category' is NOT translated here - it must stay in English for matching + * with known category keys. Translation happens at display time in menuIdevicesCompose. */ - configParamsTranslatables = ['category', 'title']; + configParamsTranslatables = ['title']; /** * Default values of config.xml params @@ -207,9 +209,19 @@ export default class Idevice { * @returns {String} */ getResourceServicePath(path) { - let pathServiceResources = - this.manager.app.api.endpoints.api_idevices_download_file_resources - .path; + // Static mode: bundled iDevice files are served directly + if (path.includes('/files/perm/idevices/')) { + return path; + } + + // Check if endpoint exists (may not exist in static mode) + const endpoint = + this.manager.app.api.endpoints.api_idevices_download_file_resources; + if (!endpoint) { + return path; // Return as-is if no endpoint available + } + + let pathServiceResources = endpoint.path; let pathSplit = path.split('/files/'); let pathParam = pathSplit.length == 2 ? pathSplit[1] : path; let pathServiceResourceContentCss = `${pathServiceResources}?resource=${pathParam}`; @@ -220,7 +232,7 @@ export default class Idevice { pathServiceResources, pathSplit, pathParam, - finalUrl: pathServiceResourceContentCss + finalUrl: pathServiceResourceContentCss, }); return pathServiceResourceContentCss; diff --git a/public/app/workarea/idevices/idevice.test.js b/public/app/workarea/idevices/idevice.test.js index 1c9dd143a..484597180 100644 --- a/public/app/workarea/idevices/idevice.test.js +++ b/public/app/workarea/idevices/idevice.test.js @@ -142,15 +142,15 @@ describe('Idevice', () => { }); describe('configParamsTranslatables', () => { - it('contains category and title', () => { + it('contains only title (category is NOT translated for matching purposes)', () => { const idevice = new Idevice(mockManager, mockIdeviceData); - expect(idevice.configParamsTranslatables).toContain('category'); expect(idevice.configParamsTranslatables).toContain('title'); + expect(idevice.configParamsTranslatables).not.toContain('category'); }); - it('has exactly 2 items', () => { + it('has exactly 1 item', () => { const idevice = new Idevice(mockManager, mockIdeviceData); - expect(idevice.configParamsTranslatables).toHaveLength(2); + expect(idevice.configParamsTranslatables).toHaveLength(1); }); }); @@ -212,23 +212,25 @@ describe('Idevice', () => { expect(idevice.version).toBe('2.0'); }); - it('translates translatable params', () => { + it('translates translatable params (only title, not category)', () => { const idevice = new Idevice(mockManager, mockIdeviceData); expect(window._).toHaveBeenCalledWith('Text', 'text-idevice'); - expect(window._).toHaveBeenCalledWith('Basic', 'text-idevice'); + // Category is NOT translated - it stays in English for matching + expect(window._).not.toHaveBeenCalledWith('Basic', 'text-idevice'); }); - it('sets translated values for category and title', () => { + it('sets translated value for title but NOT for category', () => { const idevice = new Idevice(mockManager, mockIdeviceData); expect(idevice.title).toBe('translated:Text'); - expect(idevice.category).toBe('translated:Basic'); + // Category stays in English for matching with known category keys + expect(idevice.category).toBe('Basic'); }); }); describe('isTranslatable', () => { - it('returns true for category', () => { + it('returns false for category (kept in English for matching)', () => { const idevice = new Idevice(mockManager, mockIdeviceData); - expect(idevice.isTranslatable('category')).toBe(true); + expect(idevice.isTranslatable('category')).toBe(false); }); it('returns true for title', () => { @@ -363,14 +365,15 @@ describe('Idevice', () => { }); describe('getResourceServicePath', () => { - it('constructs service path with resource parameter', () => { + it('returns path as-is for static mode iDevice paths', () => { + // Static mode: paths containing /files/perm/idevices/ are served directly const idevice = new Idevice(mockManager, mockIdeviceData); const result = idevice.getResourceServicePath('/files/perm/idevices/test/style.css'); - expect(result).toBe('/api/idevices/resources?resource=perm/idevices/test/style.css'); + expect(result).toBe('/files/perm/idevices/test/style.css'); }); - it('handles paths without /files/ prefix', () => { + it('handles paths without /files/ prefix via API', () => { const idevice = new Idevice(mockManager, mockIdeviceData); const result = idevice.getResourceServicePath('some/other/path.js'); @@ -380,12 +383,12 @@ describe('Idevice', () => { it('returns correct path for various inputs', () => { const idevice = new Idevice(mockManager, mockIdeviceData); - // Test with /files/ prefix + // Test with /files/perm/idevices/ prefix - static mode returns as-is expect(idevice.getResourceServicePath('/files/perm/idevices/text/style.css')).toBe( - '/api/idevices/resources?resource=perm/idevices/text/style.css' + '/files/perm/idevices/text/style.css' ); - // Test empty input + // Test empty input - goes through API expect(idevice.getResourceServicePath('')).toBe('/api/idevices/resources?resource='); }); }); diff --git a/public/app/workarea/interface/elements/logoutButton.js b/public/app/workarea/interface/elements/logoutButton.js index b4a56cbca..8a1d81e4b 100644 --- a/public/app/workarea/interface/elements/logoutButton.js +++ b/public/app/workarea/interface/elements/logoutButton.js @@ -3,6 +3,9 @@ export default class LogoutButton { this.logoutMenuHeadButton = document.querySelector( '#head-bottom-logout-button' ); + this.exitMenuHeadButton = document.querySelector( + '#head-bottom-exit-button' + ); } /** * Init element @@ -16,45 +19,57 @@ export default class LogoutButton { * */ addEventClick() { - this.logoutMenuHeadButton.addEventListener('click', (event) => { - // In offline mode (Electron), close the window instead of logging out - if (eXeLearning.config?.isOfflineInstallation) { + // Logout button handler (online mode only) + if (this.logoutMenuHeadButton) { + this.logoutMenuHeadButton.addEventListener('click', (event) => { + this.handleLogout(); + }); + } + + // Exit button handler (Electron mode only) + if (this.exitMenuHeadButton) { + this.exitMenuHeadButton.addEventListener('click', (event) => { this.handleOfflineExit(); - return; - } - let odeSessionId = eXeLearning.app.project.odeSession; - let odeVersionId = eXeLearning.app.project.odeVersion; - let odeId = eXeLearning.app.project.odeId; - let params = { - odeSessionId: odeSessionId, - odeVersionId: odeVersionId, - odeId: odeId, - }; - eXeLearning.app.api - .postCheckCurrentOdeUsers(params) - .then((response) => { - if (response['leaveSession']) { - eXeLearning.app.api - .postCloseSession(params) - .then((response) => { - window.onbeforeunload = null; - let pathname = - window.location.pathname.split('/'); - let basePathname = pathname - .splice(0, pathname.length - 1) - .join('/'); - window.location.href = - window.location.origin + - basePathname + - '/logout'; - }); - } else if (response['askSave']) { - eXeLearning.app.modals.sessionlogout.show(); - } else if (response['leaveEmptySession']) { - this.leaveEmptySession(params); - } - }); - }); + }); + } + } + + /** + * Handle logout in online mode + */ + handleLogout() { + let odeSessionId = eXeLearning.app.project.odeSession; + let odeVersionId = eXeLearning.app.project.odeVersion; + let odeId = eXeLearning.app.project.odeId; + let params = { + odeSessionId: odeSessionId, + odeVersionId: odeVersionId, + odeId: odeId, + }; + eXeLearning.app.api + .postCheckCurrentOdeUsers(params) + .then((response) => { + if (response['leaveSession']) { + eXeLearning.app.api + .postCloseSession(params) + .then((response) => { + window.onbeforeunload = null; + let pathname = + window.location.pathname.split('/'); + let basePathname = pathname + .splice(0, pathname.length - 1) + .join('/'); + window.location.href = + window.location.origin + + basePathname + + '/logout'; + }); + } else if (response['askSave']) { + eXeLearning.app.modals.sessionlogout.show(); + } else if (response['leaveEmptySession']) { + this.leaveEmptySession(params); + } + }); } /** * Handle exit in offline mode (Electron) diff --git a/public/app/workarea/interface/elements/logoutButton.test.js b/public/app/workarea/interface/elements/logoutButton.test.js index 084126f1d..e7671c751 100644 --- a/public/app/workarea/interface/elements/logoutButton.test.js +++ b/public/app/workarea/interface/elements/logoutButton.test.js @@ -2,19 +2,27 @@ import LogoutButton from './logoutButton.js'; describe('LogoutButton', () => { let logoutButton; - let mockButton; + let mockLogoutButton; + let mockExitButton; let mockPostCheckCurrentOdeUsers; let mockPostCloseSession; let mockSessionLogoutModal; let mockConfirmModal; beforeEach(() => { - // Mock DOM element - mockButton = { + // Mock DOM elements + mockLogoutButton = { + addEventListener: vi.fn(), + }; + mockExitButton = { addEventListener: vi.fn(), }; - vi.spyOn(document, 'querySelector').mockReturnValue(mockButton); + vi.spyOn(document, 'querySelector').mockImplementation((selector) => { + if (selector === '#head-bottom-logout-button') return mockLogoutButton; + if (selector === '#head-bottom-exit-button') return mockExitButton; + return null; + }); // Mock translation function window._ = vi.fn((text) => text); @@ -77,8 +85,16 @@ describe('LogoutButton', () => { expect(document.querySelector).toHaveBeenCalledWith('#head-bottom-logout-button'); }); - it('should store the button element reference', () => { - expect(logoutButton.logoutMenuHeadButton).toBe(mockButton); + it('should query the exit button element', () => { + expect(document.querySelector).toHaveBeenCalledWith('#head-bottom-exit-button'); + }); + + it('should store the logout button element reference', () => { + expect(logoutButton.logoutMenuHeadButton).toBe(mockLogoutButton); + }); + + it('should store the exit button element reference', () => { + expect(logoutButton.exitMenuHeadButton).toBe(mockExitButton); }); }); @@ -91,34 +107,38 @@ describe('LogoutButton', () => { }); describe('addEventClick', () => { - it('should add click event listener to button', () => { + it('should add click event listener to logout button', () => { + logoutButton.addEventClick(); + expect(mockLogoutButton.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); + }); + + it('should add click event listener to exit button', () => { logoutButton.addEventClick(); - expect(mockButton.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); + expect(mockExitButton.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); }); - describe('offline mode (Electron)', () => { + describe('exit button (Electron mode)', () => { let mockWindowClose; beforeEach(() => { mockWindowClose = vi.fn(); window.close = mockWindowClose; - window.eXeLearning.config = { isOfflineInstallation: true }; }); - it('should call handleOfflineExit in offline mode', async () => { + it('should call handleOfflineExit when exit button is clicked', async () => { const spy = vi.spyOn(logoutButton, 'handleOfflineExit'); logoutButton.addEventClick(); - const clickHandler = mockButton.addEventListener.mock.calls[0][1]; + const clickHandler = mockExitButton.addEventListener.mock.calls[0][1]; await clickHandler(new Event('click')); expect(spy).toHaveBeenCalled(); }); - it('should not call API in offline mode', async () => { + it('should not call API when exit button is clicked', async () => { logoutButton.addEventClick(); - const clickHandler = mockButton.addEventListener.mock.calls[0][1]; + const clickHandler = mockExitButton.addEventListener.mock.calls[0][1]; await clickHandler(new Event('click')); expect(mockPostCheckCurrentOdeUsers).not.toHaveBeenCalled(); @@ -235,7 +255,7 @@ describe('LogoutButton', () => { it('should call postCheckCurrentOdeUsers with correct params', async () => { logoutButton.addEventClick(); - const clickHandler = mockButton.addEventListener.mock.calls[0][1]; + const clickHandler = mockLogoutButton.addEventListener.mock.calls[0][1]; await clickHandler(new Event('click')); expect(mockPostCheckCurrentOdeUsers).toHaveBeenCalledWith({ @@ -253,7 +273,7 @@ describe('LogoutButton', () => { it('should call postCloseSession when leaveSession is true', async () => { logoutButton.addEventClick(); - const clickHandler = mockButton.addEventListener.mock.calls[0][1]; + const clickHandler = mockLogoutButton.addEventListener.mock.calls[0][1]; await clickHandler(new Event('click')); expect(mockPostCloseSession).toHaveBeenCalledWith({ @@ -266,7 +286,7 @@ describe('LogoutButton', () => { it('should clear onbeforeunload handler', async () => { logoutButton.addEventClick(); - const clickHandler = mockButton.addEventListener.mock.calls[0][1]; + const clickHandler = mockLogoutButton.addEventListener.mock.calls[0][1]; await clickHandler(new Event('click')); await vi.waitFor(() => { @@ -277,7 +297,7 @@ describe('LogoutButton', () => { it('should redirect to logout page', async () => { logoutButton.addEventClick(); - const clickHandler = mockButton.addEventListener.mock.calls[0][1]; + const clickHandler = mockLogoutButton.addEventListener.mock.calls[0][1]; await clickHandler(new Event('click')); await vi.waitFor(() => { @@ -289,7 +309,7 @@ describe('LogoutButton', () => { window.location.pathname = '/my/custom/path/workarea'; logoutButton.addEventClick(); - const clickHandler = mockButton.addEventListener.mock.calls[0][1]; + const clickHandler = mockLogoutButton.addEventListener.mock.calls[0][1]; await clickHandler(new Event('click')); await vi.waitFor(() => { @@ -306,7 +326,7 @@ describe('LogoutButton', () => { it('should show session logout modal when askSave is true', async () => { logoutButton.addEventClick(); - const clickHandler = mockButton.addEventListener.mock.calls[0][1]; + const clickHandler = mockLogoutButton.addEventListener.mock.calls[0][1]; await clickHandler(new Event('click')); expect(mockSessionLogoutModal.show).toHaveBeenCalled(); @@ -315,7 +335,7 @@ describe('LogoutButton', () => { it('should not call postCloseSession', async () => { logoutButton.addEventClick(); - const clickHandler = mockButton.addEventListener.mock.calls[0][1]; + const clickHandler = mockLogoutButton.addEventListener.mock.calls[0][1]; await clickHandler(new Event('click')); expect(mockPostCloseSession).not.toHaveBeenCalled(); @@ -331,7 +351,7 @@ describe('LogoutButton', () => { const spy = vi.spyOn(logoutButton, 'leaveEmptySession'); logoutButton.addEventClick(); - const clickHandler = mockButton.addEventListener.mock.calls[0][1]; + const clickHandler = mockLogoutButton.addEventListener.mock.calls[0][1]; await clickHandler(new Event('click')); expect(spy).toHaveBeenCalledWith({ @@ -430,7 +450,7 @@ describe('LogoutButton', () => { mockPostCheckCurrentOdeUsers.mockResolvedValue({ leaveSession: true }); logoutButton.init(); - const clickHandler = mockButton.addEventListener.mock.calls[0][1]; + const clickHandler = mockLogoutButton.addEventListener.mock.calls[0][1]; await clickHandler(new Event('click')); expect(mockPostCheckCurrentOdeUsers).toHaveBeenCalled(); @@ -444,7 +464,7 @@ describe('LogoutButton', () => { mockPostCheckCurrentOdeUsers.mockResolvedValue({ askSave: true }); logoutButton.init(); - const clickHandler = mockButton.addEventListener.mock.calls[0][1]; + const clickHandler = mockLogoutButton.addEventListener.mock.calls[0][1]; await clickHandler(new Event('click')); expect(mockPostCheckCurrentOdeUsers).toHaveBeenCalled(); @@ -456,7 +476,7 @@ describe('LogoutButton', () => { mockPostCheckCurrentOdeUsers.mockResolvedValue({ leaveEmptySession: true }); logoutButton.init(); - const clickHandler = mockButton.addEventListener.mock.calls[0][1]; + const clickHandler = mockLogoutButton.addEventListener.mock.calls[0][1]; await clickHandler(new Event('click')); expect(mockConfirmModal.show).toHaveBeenCalled(); diff --git a/public/app/workarea/interface/elements/previewPanel.js b/public/app/workarea/interface/elements/previewPanel.js index 23a63af85..50b30a343 100644 --- a/public/app/workarea/interface/elements/previewPanel.js +++ b/public/app/workarea/interface/elements/previewPanel.js @@ -466,18 +466,76 @@ export default class PreviewPanelManager { const documentManager = yjsBridge.documentManager; const resourceFetcher = yjsBridge.resourceFetcher || null; - // Get theme URL + // Check if we're in static mode (no remote storage capability) + const capabilities = eXeLearning.app?.capabilities; + const isStaticMode = !capabilities?.storage?.remote; + + // Get the static base path from current URL (handles subdirectory deployments) + // e.g., /exelearning/pr-preview/pr-17/ -> /exelearning/pr-preview/pr-17 + const staticBasePath = isStaticMode + ? window.location.pathname.replace(/\/?(index\.html)?$/, '') + : ''; + + // Get theme URL (same logic as generatePreviewHtml) const selectedTheme = eXeLearning.app?.themes?.selected; let themeUrl = selectedTheme?.path || null; - if (themeUrl && !themeUrl.startsWith('http')) { - themeUrl = window.location.origin + themeUrl; + let userThemeCss = null; + let userThemeJs = null; + + // Check if it's a user theme (imported from ELPX, stored in IndexedDB) + const isUserTheme = selectedTheme?.isUserTheme || themeUrl?.startsWith('user-theme://'); + + if (isUserTheme) { + // For user themes, get the CSS and JS content directly from ResourceFetcher + try { + const themeName = selectedTheme?.id || themeUrl?.replace('user-theme://', ''); + if (themeName && resourceFetcher) { + let themeFiles = resourceFetcher.getUserTheme(themeName); + if (!themeFiles && resourceFetcher.getUserThemeAsync) { + themeFiles = await resourceFetcher.getUserThemeAsync(themeName); + } + + if (themeFiles) { + // Get CSS + const styleCssBlob = themeFiles.get('style.css') || themeFiles.get(`${themeName}/style.css`); + if (styleCssBlob) { + let cssText = await styleCssBlob.text(); + cssText = await this.processUserThemeCssUrls(cssText, themeFiles, themeName); + userThemeCss = cssText; + Logger.log(`[PreviewPanel] Loaded user theme CSS for standalone preview (${userThemeCss.length} chars)`); + } + + // Get JS + const styleJsBlob = themeFiles.get('style.js') || themeFiles.get(`${themeName}/style.js`); + if (styleJsBlob) { + userThemeJs = await styleJsBlob.text(); + Logger.log(`[PreviewPanel] Loaded user theme JS for standalone preview (${userThemeJs.length} chars)`); + } + } + } + } catch (error) { + Logger.warn('[PreviewPanel] Failed to load user theme CSS/JS for standalone:', error); + } + themeUrl = null; + } else if (themeUrl && !themeUrl.startsWith('http') && !themeUrl.startsWith('blob:') && !themeUrl.startsWith('exe:')) { + // Make theme URL absolute for standalone preview (blob URL context) + const cleanThemeUrl = themeUrl.startsWith('./') ? themeUrl.slice(2) : + themeUrl.startsWith('/') ? themeUrl.slice(1) : themeUrl; + const base = isStaticMode + ? `${window.location.origin}${staticBasePath}` + : window.location.origin; + themeUrl = `${base}/${cleanThemeUrl}`; } const previewOptions = { baseUrl: window.location.origin, - basePath: eXeLearning.app.config?.basePath || '', - version: eXeLearning.app.config?.version || 'v1', + // basePath MUST start with '/' to trigger isPreviewMode=true in the exporter + basePath: isStaticMode ? '/' : (eXeLearning.app.config?.basePath || '/'), + version: window.eXeLearning?.config?.version || 'v1.0.0', + isStaticMode: isStaticMode, themeUrl: themeUrl, + userThemeCss: userThemeCss, + userThemeJs: userThemeJs, }; // Generate preview @@ -491,10 +549,30 @@ export default class PreviewPanelManager { throw new Error(result.error || 'Failed to generate preview'); } + // In static mode, convert relative URLs to absolute URLs + // This is essential for standalone preview (new tab) to work correctly + // because blob URLs cannot resolve relative paths + let generatedHtml = result.html; + if (isStaticMode) { + const fullBase = `${window.location.origin}${staticBasePath}`; + // Convert URLs starting with / to absolute URLs + // Matches: href="/path" or src="/path" (but not href="//..." or href="http...") + generatedHtml = generatedHtml.replace( + /((?:href|src)=["'])(\/(?!\/|[a-z]+:))([^"']+)(["'])/gi, + (match, prefix, slash, path, quote) => { + // /v1/libs/... -> fullBase/libs/... (strip version prefix if present) + let cleanPath = path; + cleanPath = cleanPath.replace(/^v[^/]*\//, ''); + return `${prefix}${fullBase}/${cleanPath}${quote}`; + } + ); + Logger.log('[PreviewPanel] Converted relative URLs to absolute for standalone preview, base:', fullBase); + } + // Add MIME types to media elements let html = typeof window.addMediaTypes === 'function' - ? window.addMediaTypes(result.html) - : result.html; + ? window.addMediaTypes(generatedHtml) + : generatedHtml; // Simplify MediaElement.js structures if (typeof window.simplifyMediaElements === 'function') { @@ -548,16 +626,98 @@ export default class PreviewPanelManager { // Get theme URL from currently selected theme (handles admin vs builtin themes) // Ensure it's an absolute URL (blob: contexts don't resolve relative URLs correctly) const selectedTheme = eXeLearning.app?.themes?.selected; + // Check if we're in static mode (no remote storage capability) + const capabilities = eXeLearning.app?.capabilities; + const isStaticMode = !capabilities?.storage?.remote; let themeUrl = selectedTheme?.path || null; - if (themeUrl && !themeUrl.startsWith('http')) { - themeUrl = window.location.origin + themeUrl; + let userThemeCss = null; + let userThemeJs = null; + + // For preview (loaded via blob URL), we ALWAYS need absolute URLs + // because blob URLs cannot resolve relative paths. + // In static mode: + // - Files may be in a subdirectory (e.g., /exelearning/pr-preview/pr-17/) + // - We need the full base including pathname, not just origin + // - basePath should be the pathname directory + // In server mode: + // - Files use versioned paths (/v1/libs/bootstrap.min.css) + // - baseUrl is origin, basePath may be set + + // Get the static base path from current URL (handles subdirectory deployments) + // e.g., /exelearning/pr-preview/pr-17/ -> /exelearning/pr-preview/pr-17 + const staticBasePath = isStaticMode + ? window.location.pathname.replace(/\/?(index\.html)?$/, '') + : ''; + + // Check if it's a user theme (imported from ELPX, stored in IndexedDB) + // User themes use the 'user-theme://' pseudo-protocol which isn't a valid HTTP URL + const isUserTheme = selectedTheme?.isUserTheme || themeUrl?.startsWith('user-theme://'); + + if (isUserTheme) { + // For user themes, get the CSS and JS content directly from ResourceFetcher + // and pass them as inline styles/scripts to the exporter + console.log(`[PreviewPanel] Detected USER THEME, loading CSS/JS inline...`); + try { + const themeName = selectedTheme?.id || themeUrl?.replace('user-theme://', ''); + console.log(`[PreviewPanel] Theme name: ${themeName}, resourceFetcher available: ${!!resourceFetcher}`); + if (themeName && resourceFetcher) { + // Try async method that fetches from IndexedDB if needed + let themeFiles = resourceFetcher.getUserTheme(themeName); + console.log(`[PreviewPanel] getUserTheme sync result: ${themeFiles ? themeFiles.size + ' files' : 'null'}`); + if (!themeFiles && resourceFetcher.getUserThemeAsync) { + themeFiles = await resourceFetcher.getUserThemeAsync(themeName); + console.log(`[PreviewPanel] getUserThemeAsync result: ${themeFiles ? themeFiles.size + ' files' : 'null'}`); + } + + if (themeFiles) { + // Find style.css in theme files + const styleCssBlob = themeFiles.get('style.css') || themeFiles.get(`${themeName}/style.css`); + console.log(`[PreviewPanel] style.css found: ${!!styleCssBlob}`); + if (styleCssBlob) { + let cssText = await styleCssBlob.text(); + // Process CSS to convert url() references to data URLs + // (fonts, icons, images referenced in CSS won't load without this) + cssText = await this.processUserThemeCssUrls(cssText, themeFiles, themeName); + userThemeCss = cssText; + console.log(`[PreviewPanel] Loaded user theme CSS for '${themeName}' (${userThemeCss.length} chars)`); + } + + // Find style.js in theme files (handles togglers, dark mode, etc.) + const styleJsBlob = themeFiles.get('style.js') || themeFiles.get(`${themeName}/style.js`); + console.log(`[PreviewPanel] style.js found: ${!!styleJsBlob}`); + if (styleJsBlob) { + userThemeJs = await styleJsBlob.text(); + console.log(`[PreviewPanel] Loaded user theme JS for '${themeName}' (${userThemeJs.length} chars)`); + } + } + } + } catch (error) { + console.error('[PreviewPanel] Failed to load user theme CSS/JS:', error); + } + themeUrl = null; // Don't use invalid user-theme:// URL + } else if (themeUrl && !themeUrl.startsWith('http') && !themeUrl.startsWith('blob:') && !themeUrl.startsWith('exe:')) { + // Make theme URL absolute for blob URL context + // Remove leading ./ if present and make absolute + const cleanThemeUrl = themeUrl.startsWith('./') ? themeUrl.slice(2) : + themeUrl.startsWith('/') ? themeUrl.slice(1) : themeUrl; + const base = isStaticMode + ? `${window.location.origin}${staticBasePath}` + : window.location.origin; + themeUrl = `${base}/${cleanThemeUrl}`; } const previewOptions = { + // Always use absolute URLs for preview (blob URL context) baseUrl: window.location.origin, - basePath: eXeLearning.app.config?.basePath || '', - version: eXeLearning.app.config?.version || 'v1', - themeUrl: themeUrl, // Full absolute theme URL (e.g., 'http://localhost:8081/v1/site-files/themes/chiquito/') + // basePath MUST start with '/' to trigger isPreviewMode=true in the exporter + // This ensures asset:// URLs are preserved (not converted to server paths) + // and will be resolved to blob URLs from IndexedDB by resolveAssetUrlsAsync + basePath: isStaticMode ? '/' : (eXeLearning.app.config?.basePath || '/'), + version: window.eXeLearning?.config?.version || 'v1.0.0', + isStaticMode: isStaticMode, + themeUrl: themeUrl, // Absolute theme URL (e.g., 'http://localhost:8081/v1/site-files/themes/chiquito/') + userThemeCss: userThemeCss, // Inline CSS for user themes (from IndexedDB) + userThemeJs: userThemeJs, // Inline JS for user themes (from IndexedDB) }; // Generate preview @@ -571,11 +731,33 @@ export default class PreviewPanelManager { throw new Error(result.error || 'Failed to generate preview'); } + // In static mode, the exporter generates URLs with basePath='/' + // These need to be converted to absolute URLs for blob URL context + // Important: Use full base path including pathname for subdirectory deployments + let generatedHtml = result.html; + if (isStaticMode) { + const fullBase = `${window.location.origin}${staticBasePath}`; + // Convert URLs starting with / to absolute URLs + // Matches: href="/path" or src="/path" (but not href="//..." or href="http...") + generatedHtml = generatedHtml.replace( + /((?:href|src)=["'])(\/(?!\/|[a-z]+:))([^"']+)(["'])/gi, + (match, prefix, slash, path, quote) => { + // /v1/libs/... -> fullBase/libs/... (strip version prefix if present) + // /libs/... -> fullBase/libs/... + let cleanPath = path; + // Remove version prefix if present (e.g., /v1/, /v0.0.0-alpha/) + cleanPath = cleanPath.replace(/^v[^/]*\//, ''); + return `${prefix}${fullBase}/${cleanPath}${quote}`; + } + ); + Logger.log('[PreviewPanel] Converted relative URLs to absolute for static mode, base:', fullBase); + } + // Add MIME types to media elements BEFORE resolving URLs // (while asset:// URLs still contain filename with extension) let html = typeof window.addMediaTypes === 'function' - ? window.addMediaTypes(result.html) - : result.html; + ? window.addMediaTypes(generatedHtml) + : generatedHtml; // Simplify MediaElement.js structures to native HTML5 video/audio // (fixes playback issues with large videos) @@ -1569,6 +1751,109 @@ export default class PreviewPanelManager { Logger.log('[PreviewPanel] Auto-refresh:', enabled ? 'enabled' : 'disabled'); } + /** + * Process user theme CSS to convert url() references to data URLs. + * Theme assets (fonts, icons, images) are stored in IndexedDB and need to be + * embedded as data URLs for the inline CSS to work in the preview. + * + * @param {string} cssText - The CSS content + * @param {Map} themeFiles - Map of theme file paths to Blobs + * @param {string} themeName - Theme directory name (for path resolution) + * @returns {Promise} CSS with url() references converted to data URLs + */ + async processUserThemeCssUrls(cssText, themeFiles, themeName) { + // Log available theme files for debugging (use console.log to always show) + const availableFiles = Array.from(themeFiles.keys()); + console.log(`[PreviewPanel] Theme '${themeName}' files available (${availableFiles.length}):`, availableFiles); + + // Find all url() references in the CSS + // Matches: url("path"), url('path'), url(path) + const urlRegex = /url\(\s*(['"]?)([^'")\s]+)\1\s*\)/gi; + + // Collect all unique URLs and their replacements + const urlReplacements = new Map(); + let match; + + while ((match = urlRegex.exec(cssText)) !== null) { + const originalUrl = match[0]; + const urlPath = match[2]; + + // Skip if already processed, or if it's an absolute URL, data URL, or blob URL + if (urlReplacements.has(originalUrl)) continue; + if (urlPath.startsWith('http://') || urlPath.startsWith('https://')) continue; + if (urlPath.startsWith('data:') || urlPath.startsWith('blob:')) continue; + if (urlPath.startsWith('#')) continue; // SVG references + + // Normalize the path (remove leading ./ or ../) + let normalizedPath = urlPath + .replace(/^\.\//, '') // ./path -> path + .replace(/^\.\.\//, ''); // ../path -> path (relative to theme root) + + // Try to find the file in themeFiles with various path combinations + // Theme CSS references like url(fonts/file.woff2), url(img/icons.png) + // Files are stored without theme/ prefix: fonts/file.woff2, img/icons.png + const pathsToTry = [ + normalizedPath, // Direct match: fonts/file.woff2 + `${themeName}/${normalizedPath}`, // With theme prefix + normalizedPath.replace(/^fonts\//, 'fonts/'), // Ensure fonts/ stays + normalizedPath.replace(/^img\//, 'img/'), // Ensure img/ stays + normalizedPath.replace(/^icons\//, 'icons/'), // Ensure icons/ stays + normalizedPath.replace(/^images\//, 'images/'), // Alternative folder name + ]; + + let blob = null; + let foundPath = null; + for (const tryPath of pathsToTry) { + blob = themeFiles.get(tryPath); + if (blob) { + foundPath = tryPath; + break; + } + } + + if (blob) { + try { + // Convert blob to data URL + const dataUrl = await this.blobToDataUrl(blob); + urlReplacements.set(originalUrl, `url("${dataUrl}")`); + console.log(`[PreviewPanel] ✓ Converted: ${foundPath}`); + } catch (error) { + console.warn(`[PreviewPanel] ✗ Failed to convert ${urlPath}:`, error); + } + } else { + // Only warn for files that might exist (not external resources) + const isLikelyMissing = normalizedPath.match(/\.(woff2?|ttf|eot|svg|png|jpg|jpeg|gif|webp)$/i); + if (isLikelyMissing) { + console.warn(`[PreviewPanel] ✗ NOT FOUND: "${urlPath}" (normalized: "${normalizedPath}")`); + } + } + } + + // Apply all replacements + let processedCss = cssText; + for (const [original, replacement] of urlReplacements) { + // Use split/join for global replacement (escaping regex special chars) + processedCss = processedCss.split(original).join(replacement); + } + + console.log(`[PreviewPanel] Processed ${urlReplacements.size} CSS url() references`); + return processedCss; + } + + /** + * Convert a Blob to a data URL + * @param {Blob} blob - The blob to convert + * @returns {Promise} Data URL + */ + blobToDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } + /** * Escape HTML special characters * @param {string} text - Text to escape diff --git a/public/app/workarea/interface/elements/previewPanel.test.js b/public/app/workarea/interface/elements/previewPanel.test.js index bd12eb29b..31561ff7a 100644 --- a/public/app/workarea/interface/elements/previewPanel.test.js +++ b/public/app/workarea/interface/elements/previewPanel.test.js @@ -1129,7 +1129,7 @@ describe('PreviewPanelManager', () => { manager.subscribeToChanges(); const unsubscribeSpy = vi.fn(); manager._unsubscribeStructure = unsubscribeSpy; - + // Setup blobUrl to test revocation mockElements['preview-iframe']._blobUrl = 'blob:test-1'; mockElements['preview-pinned-iframe']._blobUrl = 'blob:test-2'; @@ -1141,4 +1141,259 @@ describe('PreviewPanelManager', () => { expect(global.URL.revokeObjectURL).toHaveBeenCalledTimes(2); }); }); + + describe('blobToDataUrl', () => { + it('should convert blob to data URL', async () => { + const blob = new Blob(['test content'], { type: 'text/plain' }); + const result = await manager.blobToDataUrl(blob); + + expect(result).toContain('data:text/plain'); + expect(result).toContain('base64'); + }); + + it('should handle image blobs', async () => { + // Create a simple 1x1 PNG-like blob + const blob = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' }); + const result = await manager.blobToDataUrl(blob); + + expect(result).toContain('data:image/png'); + }); + }); + + describe('processUserThemeCssUrls', () => { + it('should return css unchanged when no url() references', async () => { + const cssText = 'body { color: red; }'; + const themeFiles = new Map(); + const result = await manager.processUserThemeCssUrls(cssText, themeFiles, 'test-theme'); + expect(result).toBe(cssText); + }); + + it('should skip absolute URLs', async () => { + const cssText = 'body { background: url("https://example.com/image.png"); }'; + const themeFiles = new Map(); + const result = await manager.processUserThemeCssUrls(cssText, themeFiles, 'test-theme'); + expect(result).toBe(cssText); + }); + + it('should skip data URLs', async () => { + const cssText = 'body { background: url("data:image/png;base64,abc"); }'; + const themeFiles = new Map(); + const result = await manager.processUserThemeCssUrls(cssText, themeFiles, 'test-theme'); + expect(result).toBe(cssText); + }); + + it('should skip blob URLs', async () => { + const cssText = 'body { background: url("blob:http://localhost/123"); }'; + const themeFiles = new Map(); + const result = await manager.processUserThemeCssUrls(cssText, themeFiles, 'test-theme'); + expect(result).toBe(cssText); + }); + + it('should convert relative url() to data URL when file exists', async () => { + const cssText = 'body { background: url("image.png"); }'; + const imageBlob = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }); + const themeFiles = new Map([['image.png', imageBlob]]); + + const result = await manager.processUserThemeCssUrls(cssText, themeFiles, 'test-theme'); + + expect(result).toContain('data:image/png'); + expect(result).not.toContain('image.png'); + }); + + it('should handle url() with single quotes', async () => { + const cssText = "body { background: url('image.png'); }"; + const imageBlob = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }); + const themeFiles = new Map([['image.png', imageBlob]]); + + const result = await manager.processUserThemeCssUrls(cssText, themeFiles, 'test-theme'); + + expect(result).toContain('data:image/png'); + }); + + it('should handle url() without quotes', async () => { + const cssText = 'body { background: url(image.png); }'; + const imageBlob = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }); + const themeFiles = new Map([['image.png', imageBlob]]); + + const result = await manager.processUserThemeCssUrls(cssText, themeFiles, 'test-theme'); + + expect(result).toContain('data:image/png'); + }); + + it('should try with theme name prefix', async () => { + const cssText = 'body { background: url("fonts/font.woff2"); }'; + const fontBlob = new Blob([new Uint8Array([1, 2, 3])], { type: 'font/woff2' }); + const themeFiles = new Map([['my-theme/fonts/font.woff2', fontBlob]]); + + const result = await manager.processUserThemeCssUrls(cssText, themeFiles, 'my-theme'); + + expect(result).toContain('data:font/woff2'); + }); + + it('should normalize paths with ./', async () => { + const cssText = 'body { background: url("./image.png"); }'; + const imageBlob = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }); + const themeFiles = new Map([['image.png', imageBlob]]); + + const result = await manager.processUserThemeCssUrls(cssText, themeFiles, 'test-theme'); + + expect(result).toContain('data:image/png'); + }); + + it('should handle multiple url() references', async () => { + const cssText = ` + .icon1 { background: url("icon1.png"); } + .icon2 { background: url("icon2.png"); } + `; + const themeFiles = new Map([ + ['icon1.png', new Blob([new Uint8Array([1])], { type: 'image/png' })], + ['icon2.png', new Blob([new Uint8Array([2])], { type: 'image/png' })], + ]); + + const result = await manager.processUserThemeCssUrls(cssText, themeFiles, 'test-theme'); + + expect(result).not.toContain('icon1.png'); + expect(result).not.toContain('icon2.png'); + expect(result.match(/data:image\/png/g).length).toBe(2); + }); + + it('should skip SVG hash references', async () => { + const cssText = 'body { background: url("#gradient"); }'; + const themeFiles = new Map(); + const result = await manager.processUserThemeCssUrls(cssText, themeFiles, 'test-theme'); + expect(result).toBe(cssText); + }); + + it('should leave url unchanged when file not found', async () => { + const cssText = 'body { background: url("missing.png"); }'; + const themeFiles = new Map(); + const result = await manager.processUserThemeCssUrls(cssText, themeFiles, 'test-theme'); + expect(result).toContain('missing.png'); + }); + }); + + describe('restorePinnedState', () => { + it('should restore pinned state from localStorage', async () => { + const mockLocalStorage = { + getItem: vi.fn(() => 'true'), + }; + Object.defineProperty(window, 'localStorage', { + value: mockLocalStorage, + writable: true, + }); + + const pinSpy = vi.spyOn(manager, 'pin').mockImplementation(() => Promise.resolve()); + await manager.restorePinnedState(); + + expect(mockLocalStorage.getItem).toHaveBeenCalledWith('exe-preview-pinned'); + expect(pinSpy).toHaveBeenCalled(); + }); + + it('should not pin if localStorage value is not true', async () => { + const mockLocalStorage = { + getItem: vi.fn(() => 'false'), + }; + Object.defineProperty(window, 'localStorage', { + value: mockLocalStorage, + writable: true, + }); + + const pinSpy = vi.spyOn(manager, 'pin'); + await manager.restorePinnedState(); + + expect(pinSpy).not.toHaveBeenCalled(); + }); + + it('should handle localStorage errors gracefully', async () => { + const mockLocalStorage = { + getItem: vi.fn(() => { + throw new Error('localStorage error'); + }), + }; + Object.defineProperty(window, 'localStorage', { + value: mockLocalStorage, + writable: true, + }); + + // Should not throw + await expect(manager.restorePinnedState()).resolves.not.toThrow(); + }); + }); + + describe('scheduleRefresh', () => { + it('should schedule refresh when open', () => { + vi.useFakeTimers(); + manager.isOpen = true; + manager.isPinned = false; + const refreshSpy = vi.spyOn(manager, 'refresh').mockImplementation(() => Promise.resolve()); + + manager.scheduleRefresh(); + + expect(manager.refreshDebounceTimer).not.toBeNull(); + vi.advanceTimersByTime(500); + expect(refreshSpy).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('should debounce multiple rapid calls', () => { + vi.useFakeTimers(); + manager.isOpen = true; + const refreshSpy = vi.spyOn(manager, 'refresh').mockImplementation(() => Promise.resolve()); + + manager.scheduleRefresh(); + manager.scheduleRefresh(); + manager.scheduleRefresh(); + + vi.advanceTimersByTime(500); + + // Should only call refresh once due to debouncing + expect(refreshSpy).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + }); + + describe('toggle', () => { + it('should open when closed', async () => { + manager.isOpen = false; + const openSpy = vi.spyOn(manager, 'open').mockImplementation(() => Promise.resolve()); + + await manager.toggle(); + + expect(openSpy).toHaveBeenCalled(); + }); + + it('should close when open', async () => { + manager.isOpen = true; + const closeSpy = vi.spyOn(manager, 'close'); + + await manager.toggle(); + + expect(closeSpy).toHaveBeenCalled(); + }); + }); + + describe('keyboard shortcuts', () => { + it('should close on Escape key when open', () => { + manager.bindEvents(); + manager.isOpen = true; + const closeSpy = vi.spyOn(manager, 'close'); + + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + document.dispatchEvent(event); + + expect(closeSpy).toHaveBeenCalled(); + }); + + it('should not close on Escape when not open', () => { + manager.bindEvents(); + manager.isOpen = false; + const closeSpy = vi.spyOn(manager, 'close'); + + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + document.dispatchEvent(event); + + expect(closeSpy).not.toHaveBeenCalled(); + }); + }); + }); diff --git a/public/app/workarea/interface/loadingScreen.js b/public/app/workarea/interface/loadingScreen.js index 4eed98960..6ee93c9ac 100644 --- a/public/app/workarea/interface/loadingScreen.js +++ b/public/app/workarea/interface/loadingScreen.js @@ -25,6 +25,8 @@ export default class LoadingScreen { setTimeout(() => { this.loadingScreenNode.classList.remove('hiding'); this.loadingScreenNode.classList.add('hide'); + // Clear any inline display style so CSS .hide class can take effect + this.loadingScreenNode.style.display = ''; // Testing: explicit visibility flag this.loadingScreenNode.setAttribute('data-visible', 'false'); }, this.hideTime); diff --git a/public/app/workarea/interface/loadingScreen.test.js b/public/app/workarea/interface/loadingScreen.test.js index 7163f7122..da12347d6 100644 --- a/public/app/workarea/interface/loadingScreen.test.js +++ b/public/app/workarea/interface/loadingScreen.test.js @@ -14,6 +14,9 @@ describe('LoadingScreen', () => { remove: vi.fn(), }, setAttribute: vi.fn(), + style: { + display: '', + }, }; vi.spyOn(document, 'querySelector').mockReturnValue(mockElement); diff --git a/public/app/workarea/menus/idevices/menuIdevicesCompose.js b/public/app/workarea/menus/idevices/menuIdevicesCompose.js index 2e7a91a05..e00e34d66 100644 --- a/public/app/workarea/menus/idevices/menuIdevicesCompose.js +++ b/public/app/workarea/menus/idevices/menuIdevicesCompose.js @@ -18,23 +18,25 @@ export default class MenuIdevicesCompose { ); this.readers = []; } - categoriesTitle = { - information: _('Information and presentation'), - evaluation: _('Assessment and tracking'), - games: _('Games'), - interactive: _('Interactive activities'), - science: _('Science'), - imported: _('Imported'), + + // English category keys used in config.xml files - these are constant + // DO NOT translate these - they must match the backend category values + categoryKeys = { + information: 'Information and presentation', + evaluation: 'Assessment and tracking', + games: 'Games', + interactive: 'Interactive activities', + science: 'Science', + imported: 'Imported', }; - categoriesFirst = [ - this.categoriesTitle.information, - this.categoriesTitle.evaluation, - this.categoriesTitle.games, - this.categoriesTitle.interactive, - this.categoriesTitle.science, - // this.categoriesTitle.imported, // To do (see #381) - ]; + // Order of categories to display (using English keys) + categoriesOrder = ['information', 'evaluation', 'games', 'interactive', 'science']; + + // Get translated title for a category key (called at render time) + getCategoryTitle(key) { + return _(this.categoryKeys[key] || key); + } /** * Generate the HTML in the idevices menu @@ -44,28 +46,32 @@ export default class MenuIdevicesCompose { // Clean menu this.categoriesExtra = []; this.categoriesIdevices = {}; - this.categoriesIcons = []; + this.categoryKeyToIcon = {}; this.menuIdevices.innerHTML = ''; - // Set categories - for (let [key, title] of Object.entries(this.categoriesTitle)) { - this.categoriesIdevices[title] = []; - this.categoriesIcons[title] = key; + + // Build reverse lookup: English category name -> icon key + this.englishToKey = {}; + for (let [iconKey, englishName] of Object.entries(this.categoryKeys)) { + this.englishToKey[englishName] = iconKey; + this.categoriesIdevices[englishName] = []; + this.categoryKeyToIcon[englishName] = iconKey; } + this.addIdevicesToCategory(); - // Generate elements - this.orderedCategories = this.categoriesFirst.concat( - this.categoriesExtra - ); - this.orderedCategories.forEach((category) => { - if ( - this.categoriesIdevices[category] - //&& this.categoriesIdevices[category].length > 0 - ) { + // Generate elements - use English category names for lookup + const orderedEnglishCategories = this.categoriesOrder.map(key => this.categoryKeys[key]); + const allCategories = orderedEnglishCategories.concat(this.categoriesExtra); + + allCategories.forEach((englishCategory) => { + if (this.categoriesIdevices[englishCategory]) { + // Use known icon key, or create a safe CSS class name for unknown categories + const iconKey = this.categoryKeyToIcon[englishCategory] || + englishCategory.toLowerCase().replace(/[^a-z0-9]/g, '-'); this.createDivCategoryIdevices( - category, - this.categoriesIdevices[category], - this.categoriesIcons[category] + englishCategory, + this.categoriesIdevices[englishCategory], + iconKey ); } }); @@ -73,31 +79,33 @@ export default class MenuIdevicesCompose { /** * Add idevices to categories + * Uses English category names from backend to match with known categories * * @return dict */ addIdevicesToCategory() { for (let [key, idevice] of Object.entries(this.idevicesInstalled)) { - // TODO commented only for develop -> if (idevice.visible) { - if (!this.categoriesIdevices[idevice.category]) { - this.categoriesIdevices[idevice.category] = []; - this.categoriesExtra.push(idevice.category); + // idevice.category is in English (from config.xml) + const category = idevice.category; + if (!this.categoriesIdevices[category]) { + this.categoriesIdevices[category] = []; + this.categoriesExtra.push(category); } - this.categoriesIdevices[idevice.category].push(idevice); - // } + this.categoriesIdevices[category].push(idevice); } } /** * Create node parent category * - * @param {*} categoryTitle + * @param {string} englishCategory - English category name (e.g., "Information and presentation") * @param {*} idevices + * @param {string} icon - Icon key (e.g., "information") */ - createDivCategoryIdevices(categoryTitle, idevices, icon) { - // The Text iDevice should be in the first place - if (categoryTitle == this.categoriesTitle.information) { - // Find the object widh id == "text" + createDivCategoryIdevices(englishCategory, idevices, icon) { + // The Text iDevice should be in the first place for "Information and presentation" + if (englishCategory === this.categoryKeys.information) { + // Find the object with id == "text" const index = idevices.findIndex((obj) => obj.id === 'text'); if (index > -1) { // Put Text in the first place @@ -105,8 +113,10 @@ export default class MenuIdevicesCompose { idevices.unshift(item); } } - let nodeDivCategory = this.elementDivCategory(categoryTitle); - nodeDivCategory.append(this.elementLabelCategory(categoryTitle, icon)); + // Translate the category title for display + const translatedTitle = _(englishCategory); + let nodeDivCategory = this.elementDivCategory(translatedTitle); + nodeDivCategory.append(this.elementLabelCategory(translatedTitle, icon)); nodeDivCategory.append(this.elementDivIdevicesParent(idevices, icon)); this.menuIdevices.append(nodeDivCategory); } diff --git a/public/app/workarea/menus/idevices/menuIdevicesCompose.test.js b/public/app/workarea/menus/idevices/menuIdevicesCompose.test.js index 64aaa072a..b035b8f5b 100644 --- a/public/app/workarea/menus/idevices/menuIdevicesCompose.test.js +++ b/public/app/workarea/menus/idevices/menuIdevicesCompose.test.js @@ -143,40 +143,50 @@ describe('MenuIdevicesCompose', () => { }); }); - describe('categoriesTitle', () => { + describe('categoryKeys', () => { it('should have information category', () => { - expect(menuIdevicesCompose.categoriesTitle.information).toBe('Information and presentation'); + expect(menuIdevicesCompose.categoryKeys.information).toBe('Information and presentation'); }); it('should have evaluation category', () => { - expect(menuIdevicesCompose.categoriesTitle.evaluation).toBe('Assessment and tracking'); + expect(menuIdevicesCompose.categoryKeys.evaluation).toBe('Assessment and tracking'); }); it('should have games category', () => { - expect(menuIdevicesCompose.categoriesTitle.games).toBe('Games'); + expect(menuIdevicesCompose.categoryKeys.games).toBe('Games'); }); it('should have interactive category', () => { - expect(menuIdevicesCompose.categoriesTitle.interactive).toBe('Interactive activities'); + expect(menuIdevicesCompose.categoryKeys.interactive).toBe('Interactive activities'); }); it('should have science category', () => { - expect(menuIdevicesCompose.categoriesTitle.science).toBe('Science'); + expect(menuIdevicesCompose.categoryKeys.science).toBe('Science'); }); it('should have imported category', () => { - expect(menuIdevicesCompose.categoriesTitle.imported).toBe('Imported'); + expect(menuIdevicesCompose.categoryKeys.imported).toBe('Imported'); }); }); - describe('categoriesFirst', () => { - it('should have ordered categories', () => { - expect(menuIdevicesCompose.categoriesFirst).toHaveLength(5); - expect(menuIdevicesCompose.categoriesFirst[0]).toBe('Information and presentation'); - expect(menuIdevicesCompose.categoriesFirst[1]).toBe('Assessment and tracking'); - expect(menuIdevicesCompose.categoriesFirst[2]).toBe('Games'); - expect(menuIdevicesCompose.categoriesFirst[3]).toBe('Interactive activities'); - expect(menuIdevicesCompose.categoriesFirst[4]).toBe('Science'); + describe('categoriesOrder', () => { + it('should have ordered category keys', () => { + expect(menuIdevicesCompose.categoriesOrder).toHaveLength(5); + expect(menuIdevicesCompose.categoriesOrder[0]).toBe('information'); + expect(menuIdevicesCompose.categoriesOrder[1]).toBe('evaluation'); + expect(menuIdevicesCompose.categoriesOrder[2]).toBe('games'); + expect(menuIdevicesCompose.categoriesOrder[3]).toBe('interactive'); + expect(menuIdevicesCompose.categoriesOrder[4]).toBe('science'); + }); + }); + + describe('getCategoryTitle', () => { + it('should return translated title for known category', () => { + expect(menuIdevicesCompose.getCategoryTitle('information')).toBe('Information and presentation'); + }); + + it('should return the key itself for unknown category', () => { + expect(menuIdevicesCompose.getCategoryTitle('unknown')).toBe('unknown'); }); }); @@ -205,10 +215,11 @@ describe('MenuIdevicesCompose', () => { expect(spy).toHaveBeenCalled(); }); - it('should create ordered categories combining first and extra', () => { + it('should create category elements for all ordered categories', () => { menuIdevicesCompose.compose(); - expect(menuIdevicesCompose.orderedCategories).toBeDefined(); - expect(menuIdevicesCompose.orderedCategories.length).toBeGreaterThanOrEqual(5); + // Should have at least the 5 base categories rendered + const categories = mockMenuElement.querySelectorAll('.idevice_category'); + expect(categories.length).toBeGreaterThanOrEqual(5); }); it('should create category elements in menu', () => { @@ -222,9 +233,9 @@ describe('MenuIdevicesCompose', () => { beforeEach(() => { menuIdevicesCompose.categoriesIdevices = {}; menuIdevicesCompose.categoriesExtra = []; - // Initialize known categories - for (let [key, title] of Object.entries(menuIdevicesCompose.categoriesTitle)) { - menuIdevicesCompose.categoriesIdevices[title] = []; + // Initialize known categories using English category names + for (let [key, englishName] of Object.entries(menuIdevicesCompose.categoryKeys)) { + menuIdevicesCompose.categoriesIdevices[englishName] = []; } }); diff --git a/public/app/workarea/menus/navbar/items/navbarFile.js b/public/app/workarea/menus/navbar/items/navbarFile.js index 7c7c804d1..c62c5b8f2 100644 --- a/public/app/workarea/menus/navbar/items/navbarFile.js +++ b/public/app/workarea/menus/navbar/items/navbarFile.js @@ -191,6 +191,13 @@ export default class NavbarFile { * and show the "New from Template" button if so */ async checkAndShowNewFromTemplateButton() { + // Templates require server API - skip when no remote storage + // Note: Only skip if capabilities are available AND remote is explicitly false + const capabilities = eXeLearning?.app?.capabilities; + if (capabilities && !capabilities.storage.remote) { + return; + } + try { // Get current locale from eXeLearning config or default to 'en' const locale = eXeLearning?.config?.locale || 'en'; @@ -1463,6 +1470,14 @@ export default class NavbarFile { * */ openUserOdeFilesEvent() { + // Static mode: use client-side file input directly (no server storage) + // Note: Only trigger static mode if capabilities are available AND remote is explicitly false + const capabilities = eXeLearning?.app?.capabilities; + if (capabilities && !capabilities.storage.remote) { + this.openFileInputStatic(); + return; + } + if (eXeLearning.config.isOfflineInstallation === true) { // Electron offline: use native dialog so we know the real path if ( @@ -1527,6 +1542,77 @@ export default class NavbarFile { } } + /** + * Opens file input for static mode (PWA/offline) + * Uses ElpxImporter directly without server APIs + */ + openFileInputStatic() { + // Create or reuse a hidden file input + let fileInput = document.getElementById('static-open-file-input'); + if (!fileInput) { + fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.id = 'static-open-file-input'; + fileInput.accept = '.elpx,.elp'; + fileInput.style.display = 'none'; + document.body.appendChild(fileInput); + + fileInput.addEventListener('change', async (e) => { + const file = e.target.files?.[0]; + if (file) { + try { + // Show loading indicator + if (eXeLearning.app.project?.showLoadingScreen) { + eXeLearning.app.project.showLoadingScreen(); + } + + // Get the Yjs bridge - required for static mode + const yjsBridge = eXeLearning.app.project._yjsBridge; + if (!yjsBridge) { + throw new Error( + 'Yjs bridge not initialized. Please wait for the editor to load.' + ); + } + + // Use YjsBridge.importFromElpx directly (client-side, no server APIs) + Logger.log('[Static] Importing file:', file.name); + await yjsBridge.importFromElpx(file); + + // Refresh UI after import (without server calls) + if (eXeLearning.app.project?.refreshAfterDirectImport) { + await eXeLearning.app.project.refreshAfterDirectImport(); + } + + Logger.log('[Static] Import complete:', file.name); + } catch (err) { + console.error('[Static] Failed to import file:', err); + if (eXeLearning.app.modals?.alert) { + eXeLearning.app.modals.alert.show({ + title: _('Error opening'), + body: err.message || String(err), + contentId: 'error', + }); + } else { + alert( + _('Failed to open project: ') + + (err.message || err) + ); + } + } finally { + // Hide loading indicator + if (eXeLearning.app.project?.hideLoadingScreen) { + eXeLearning.app.project.hideLoadingScreen(); + } + } + } + // Reset for next use + e.target.value = ''; + }); + } + + fileInput.click(); + } + /** * getOdeFilesList * Get the ode files saved by the user diff --git a/public/app/workarea/menus/navbar/items/navbarStyles.js b/public/app/workarea/menus/navbar/items/navbarStyles.js index 2ad9bc9d2..6462fddc2 100644 --- a/public/app/workarea/menus/navbar/items/navbarStyles.js +++ b/public/app/workarea/menus/navbar/items/navbarStyles.js @@ -16,16 +16,18 @@ export default class NavbarFile { ) ); this.updateThemes(); - document - .querySelector('#exestylescontent-tab') - .addEventListener('click', () => { + const exeStylesTab = document.querySelector('#exestylescontent-tab'); + if (exeStylesTab) { + exeStylesTab.addEventListener('click', () => { this.buildBaseListThemes(); }); - document - .querySelector('#importedstylescontent-tab') - .addEventListener('click', () => { + } + const importedStylesTab = document.querySelector('#importedstylescontent-tab'); + if (importedStylesTab) { + importedStylesTab.addEventListener('click', () => { this.buildUserListThemes(); }); + } } updateThemes() { @@ -88,6 +90,8 @@ export default class NavbarFile { * */ styleManagerEvent() { + // Refresh themes list before building UI (themes may have loaded after constructor) + this.updateThemes(); this.buildBaseListThemes(); this.buildUserListThemes(); @@ -417,16 +421,28 @@ export default class NavbarFile { makeMenuThemeDownload(theme) { const li = document.createElement('li'); + const isDownloadable = theme.downloadable === '1' || theme.downloadable === 1; + + // Disable if not downloadable + if (!isDownloadable) { + li.classList.add('disabled'); + } const icon = document.createElement('span'); - icon.classList.add('small-icon', 'download-icon-green'); + if (isDownloadable) { + icon.classList.add('small-icon', 'download-icon-green'); + } else { + icon.classList.add('small-icon', 'download-icon-disabled'); + } li.appendChild(icon); li.appendChild(document.createTextNode(` ${_('Download')}`)); li.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - this.downloadThemeZip(theme); + if (isDownloadable) { + this.downloadThemeZip(theme); + } }); return li; } @@ -493,42 +509,131 @@ export default class NavbarFile { } } + /** + * Edit a theme's configuration + * For user themes: updates config in IndexedDB + * For server themes (site): uses API + */ async editTheme(dirName, fields) { - let response = await eXeLearning.app.api.putEditTheme(dirName, fields); - if (response && response.responseMessage === 'OK' && response.themes) { - eXeLearning.app.themes.list.loadThemesInstalled(); - let promise = new Promise((resolve, reject) => { - setTimeout(() => { - this.updateThemes(); - this.buildUserListThemes(); - }, 1000); - }); - return promise; - } else { - // Show alert - this.showElementAlert(_('Failed to edit the style '), response); + try { + // Find the theme by dirName or id in installed themes + const installedThemes = eXeLearning.app.themes.list.installed; + let theme = installedThemes[dirName]; + if (!theme) { + // Search by dirName property + theme = Object.values(installedThemes).find( + (t) => t.dirName === dirName || t.id === dirName || t.name === dirName + ); + } + const isUserTheme = theme?.isUserTheme || theme?.type === 'user'; + + if (isUserTheme) { + // Update config in IndexedDB + const resourceCache = eXeLearning.app.project?._yjsBridge?.resourceCache; + if (!resourceCache) { + this.showElementAlert(_('Storage not available'), {}); + return; + } + + // Extract config fields from form data + const configUpdates = fields.data || {}; + + // Use the theme name (key in IndexedDB) for the update + const themeName = theme.name || theme.dirName || dirName; + + // Update theme config in IndexedDB + await resourceCache.updateUserThemeConfig(themeName, configUpdates); + + // Update the theme object in memory + if (theme) { + Object.assign(theme, configUpdates); + if (configUpdates.title) { + theme.displayName = configUpdates.title; + } + } + + // Refresh UI + this.updateThemes(); + this.buildUserListThemes(); + Logger.log(`[NavbarStyles] User theme '${dirName}' config updated`); + return; + } + + // Server themes (site): use API + let response = await eXeLearning.app.api.putEditTheme(dirName, fields); + if (response && response.responseMessage === 'OK' && response.themes) { + eXeLearning.app.themes.list.loadThemesInstalled(); + let promise = new Promise((resolve, reject) => { + setTimeout(() => { + this.updateThemes(); + this.buildUserListThemes(); + }, 1000); + }); + return promise; + } else { + // Show alert + this.showElementAlert(_('Failed to edit the style '), response); + } + } catch (error) { + console.error('[NavbarStyles] editTheme error:', error); + this.showElementAlert(_('Failed to edit the style '), { error: error.message }); } } + /** + * Remove a user theme + * For user themes: deletes from IndexedDB + * For server themes: calls API (if allowed) + */ async removeTheme(id) { - let params = {}; - params.id = id; - let response = await eXeLearning.app.api.deleteTheme(params); - if ( - response && - response.responseMessage === 'OK' && - response.deleted && - response.deleted.name - ) { - await eXeLearning.app.themes.list.removeTheme( - response.deleted.name - ); - this.updateThemes(); - this.buildUserListThemes(); - } else { - // Show modal - setTimeout(() => { - this.showElementAlert(_('Failed to remove style'), response); - }, 1000); + try { + // Check if it's a user theme (stored in IndexedDB) + const theme = eXeLearning.app.themes.list.installed[id]; + const isUserTheme = theme?.isUserTheme || theme?.type === 'user'; + + if (isUserTheme) { + // Delete from IndexedDB + const resourceCache = eXeLearning.app.project?._yjsBridge?.resourceCache; + if (resourceCache) { + await resourceCache.deleteUserTheme(id); + Logger.log(`[NavbarStyles] User theme '${id}' deleted from IndexedDB`); + } + + // Remove from ResourceFetcher cache + const resourceFetcher = eXeLearning.app.resourceFetcher; + if (resourceFetcher) { + resourceFetcher.userThemeFiles?.delete(id); + resourceFetcher.cache?.delete(`theme:${id}`); + } + + // Remove from ThemeList + await eXeLearning.app.themes.list.removeTheme(id); + this.updateThemes(); + this.buildUserListThemes(); + + Logger.log(`[NavbarStyles] User theme '${id}' removed successfully`); + } else { + // Server theme - use API (legacy behavior) + let params = {}; + params.id = id; + let response = await eXeLearning.app.api.deleteTheme(params); + if ( + response && + response.responseMessage === 'OK' && + response.deleted && + response.deleted.name + ) { + await eXeLearning.app.themes.list.removeTheme( + response.deleted.name + ); + this.updateThemes(); + this.buildUserListThemes(); + } else { + this.showElementAlert(_('Failed to remove style'), response); + } + } + } catch (error) { + console.error('[NavbarStyles] Remove theme error:', error); + this.showElementAlert(_('Failed to remove style'), { error: error.message }); } } @@ -604,34 +709,192 @@ export default class NavbarFile { } addNewReader(file) { + // Read file as ArrayBuffer for ZIP parsing let reader = new FileReader(); this.readers.push(reader); reader.onload = (event) => { - this.uploadTheme(file.name, event.target.result); + this.uploadThemeToIndexedDB(file.name, event.target.result); }; - reader.readAsDataURL(file); + reader.readAsArrayBuffer(file); + } + + /** + * Upload theme to IndexedDB (client-side storage) + * Does NOT upload to server - themes are stored locally and synced via Yjs + * @param {string} fileName - ZIP file name + * @param {ArrayBuffer} arrayBuffer - ZIP file content + */ + async uploadThemeToIndexedDB(fileName, arrayBuffer) { + try { + // Parse ZIP with fflate + const fflate = window.fflate; + if (!fflate) { + throw new Error('fflate library not loaded'); + } + + const uint8Data = new Uint8Array(arrayBuffer); + const zip = fflate.unzipSync(uint8Data); + + // Validate config.xml exists + const configXmlData = zip['config.xml']; + if (!configXmlData) { + this.showElementAlert(_('Invalid style package'), { error: _('config.xml not found in ZIP') }); + return; + } + + // Parse config.xml + const configXml = new TextDecoder().decode(configXmlData); + const getValue = (tag) => { + const match = configXml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`)); + return match ? match[1].trim() : ''; + }; + + // Extract theme name from config.xml + let themeName = getValue('name') || fileName.replace('.zip', ''); + // Sanitize theme name for use as directory/key + const dirName = themeName.toLowerCase().replace(/[^a-z0-9_-]/g, '_'); + + // Check if theme already exists + if (eXeLearning.app.themes.list.installed[dirName]) { + this.showElementAlert(_('Style already exists'), { error: _('A style with this name already exists') }); + return; + } + + // Create theme config object + const themeConfig = { + name: dirName, + dirName: dirName, + displayName: themeName, + title: themeName, + type: 'user', + version: getValue('version') || '1.0', + author: getValue('author') || '', + license: getValue('license') || '', + description: getValue('description') || '', + downloadable: getValue('downloadable') || '1', // Default to downloadable + cssFiles: [], + js: [], + icons: {}, + valid: true, + isUserTheme: true, + }; + + // Scan for CSS, JS, and icons + for (const filePath of Object.keys(zip)) { + if (filePath.endsWith('.css') && !filePath.includes('/')) { + themeConfig.cssFiles.push(filePath); + } else if (filePath.endsWith('.js') && !filePath.includes('/')) { + themeConfig.js.push(filePath); + } else if (filePath.startsWith('icons/') && (filePath.endsWith('.png') || filePath.endsWith('.svg'))) { + const iconName = filePath.replace('icons/', '').replace(/\.(png|svg)$/, ''); + themeConfig.icons[iconName] = filePath; + } + } + + if (themeConfig.cssFiles.length === 0) { + themeConfig.cssFiles.push('style.css'); + } + + // Compress theme files for storage + const compressedFiles = fflate.zipSync(zip, { level: 6 }); + + // Get ResourceCache from YjsProjectBridge + const resourceCache = eXeLearning.app.project?._yjsBridge?.resourceCache; + if (!resourceCache) { + this.showElementAlert(_('Failed to install the new style'), { error: _('Storage not available') }); + return; + } + + // Save to IndexedDB + await resourceCache.setUserTheme(dirName, compressedFiles, themeConfig); + Logger.log(`[NavbarStyles] Theme '${dirName}' saved to IndexedDB`); + + // Register with ResourceFetcher + const resourceFetcher = eXeLearning.app.resourceFetcher; + if (resourceFetcher) { + await resourceFetcher.setUserThemeFiles(dirName, zip); + } + + // Add to installed themes (does NOT auto-select) + eXeLearning.app.themes.list.addUserTheme(themeConfig); + + // Update UI + this.updateThemes(); + this.buildUserListThemes(); + + Logger.log(`[NavbarStyles] Theme '${dirName}' installed successfully`); + } catch (error) { + console.error('[NavbarStyles] Theme upload error:', error); + this.showElementAlert(_('Failed to install the new style'), { error: error.message }); + } } + /** + * Legacy upload method - redirects to new IndexedDB upload + * @deprecated Use uploadThemeToIndexedDB instead + */ uploadTheme(fileName, fileData) { - let params = {}; - params.filename = fileName; - params.file = fileData; - eXeLearning.app.api.postUploadTheme(params).then((response) => { - if (response && response.responseMessage === 'OK') { - eXeLearning.app.themes.list.loadTheme(response.theme); - eXeLearning.app.themes.list.orderThemesInstalled(); - this.updateThemes(); - this.buildUserListThemes(); - } else { - this.showElementAlert( - _('Failed to install the new style'), - response - ); + console.warn('[NavbarStyles] uploadTheme() is deprecated, use uploadThemeToIndexedDB()'); + // Convert base64 to ArrayBuffer if needed + if (typeof fileData === 'string' && fileData.includes('base64,')) { + const base64 = fileData.split('base64,')[1]; + const binary = atob(base64); + const arrayBuffer = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + arrayBuffer[i] = binary.charCodeAt(i); } - }); + this.uploadThemeToIndexedDB(fileName, arrayBuffer.buffer); + } else { + this.uploadThemeToIndexedDB(fileName, fileData); + } } - downloadThemeZip(theme) { + /** + * Download theme as ZIP file + * For user themes: get from IndexedDB and download client-side + * For server themes: use API to download + */ + async downloadThemeZip(theme) { + // Check downloadable + const isDownloadable = theme.downloadable === '1' || theme.downloadable === 1; + if (!isDownloadable) { + this.showElementAlert(_('This style cannot be downloaded'), {}); + return; + } + + // User themes: get from IndexedDB and create ZIP client-side + if (theme.type === 'user' || theme.isUserTheme) { + try { + const resourceCache = eXeLearning.app.project?._yjsBridge?.resourceCache; + if (!resourceCache) { + this.showElementAlert(_('Storage not available'), {}); + return; + } + + // Use getUserThemeRaw to get the compressed ZIP data + const themeData = await resourceCache.getUserThemeRaw(theme.name); + if (!themeData?.compressedFiles) { + this.showElementAlert(_('Style files not found'), {}); + return; + } + + // themeData.compressedFiles is the raw compressed ZIP data (Uint8Array) + const blob = new Blob([themeData.compressedFiles], { type: 'application/zip' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${theme.name}.zip`; + link.click(); + URL.revokeObjectURL(url); + Logger.log(`[NavbarStyles] User theme '${theme.name}' downloaded`); + } catch (error) { + console.error('[NavbarStyles] Download theme error:', error); + this.showElementAlert(_('Failed to download the style'), { error: error.message }); + } + return; + } + + // Server themes: use existing API eXeLearning.app.api .getThemeZip(eXeLearning.app.project.odeSession, theme.dirName) .then((response) => { diff --git a/public/app/workarea/menus/navbar/items/navbarStyles.test.js b/public/app/workarea/menus/navbar/items/navbarStyles.test.js index bc177ef7e..4cae642a2 100644 --- a/public/app/workarea/menus/navbar/items/navbarStyles.test.js +++ b/public/app/workarea/menus/navbar/items/navbarStyles.test.js @@ -313,7 +313,7 @@ describe('NavbarStyles', () => { vi.useRealTimers(); }); - it('handles editTheme success and error paths', async () => { + it('handles editTheme success and error paths for server themes', async () => { vi.useFakeTimers(); const buildSpy = vi.spyOn(navbarStyles, 'buildUserListThemes'); eXeLearning.app.api.putEditTheme.mockResolvedValue({ @@ -321,6 +321,7 @@ describe('NavbarStyles', () => { themes: { themes: [] }, }); + // 'dir' is not a user theme, so it uses API navbarStyles.editTheme('dir', { data: {} }); await vi.runAllTimersAsync(); @@ -337,6 +338,41 @@ describe('NavbarStyles', () => { vi.useRealTimers(); }); + it('handles editTheme for user themes via IndexedDB', async () => { + const mockResourceCache = { + updateUserThemeConfig: vi.fn().mockResolvedValue(), + }; + eXeLearning.app.project._yjsBridge = { + resourceCache: mockResourceCache, + }; + + // 'user-1' is a user theme (type: 'user') with name: 'User Theme 1' + const buildSpy = vi.spyOn(navbarStyles, 'buildUserListThemes'); + await navbarStyles.editTheme('user-1', { data: { title: 'New Title', author: 'New Author' } }); + + // Uses theme.name ('User Theme 1') as the key in IndexedDB + expect(mockResourceCache.updateUserThemeConfig).toHaveBeenCalledWith('User Theme 1', { + title: 'New Title', + author: 'New Author', + }); + expect(buildSpy).toHaveBeenCalled(); + expect(eXeLearning.app.api.putEditTheme).not.toHaveBeenCalled(); + }); + + it('shows alert when user theme edit fails', async () => { + const mockResourceCache = { + updateUserThemeConfig: vi.fn().mockRejectedValue(new Error('DB error')), + }; + eXeLearning.app.project._yjsBridge = { + resourceCache: mockResourceCache, + }; + + const alertSpy = vi.spyOn(navbarStyles, 'showElementAlert'); + await navbarStyles.editTheme('user-1', { data: { title: 'New Title' } }); + + expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to edit'), expect.any(Object)); + }); + it('handles removeTheme success and error paths', async () => { eXeLearning.app.api.deleteTheme.mockResolvedValue({ responseMessage: 'OK', @@ -361,38 +397,112 @@ describe('NavbarStyles', () => { vi.useRealTimers(); }); - it('uploads theme and handles failure', async () => { - eXeLearning.app.api.postUploadTheme.mockResolvedValue({ - responseMessage: 'OK', - theme: { id: 'new' }, - }); - const buildSpy = vi.spyOn(navbarStyles, 'buildUserListThemes'); - navbarStyles.uploadTheme('theme.zip', 'data'); - await Promise.resolve(); - expect(eXeLearning.app.themes.list.loadTheme).toHaveBeenCalled(); - expect(buildSpy).toHaveBeenCalled(); + it('uploads theme (legacy method redirects to IndexedDB upload)', async () => { + // The uploadTheme method is deprecated and now redirects to uploadThemeToIndexedDB + const uploadToIndexedDBSpy = vi.spyOn(navbarStyles, 'uploadThemeToIndexedDB').mockResolvedValue(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - eXeLearning.app.api.postUploadTheme.mockResolvedValue({ - responseMessage: 'ERR', - error: 'fail', - }); - const alertSpy = vi.spyOn(navbarStyles, 'showElementAlert'); - navbarStyles.uploadTheme('theme.zip', 'data'); + // Test with base64 data + navbarStyles.uploadTheme('theme.zip', 'data:application/zip;base64,dGVzdA=='); await Promise.resolve(); - expect(alertSpy).toHaveBeenCalled(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('deprecated')); + expect(uploadToIndexedDBSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + uploadToIndexedDBSpy.mockRestore(); }); - it('downloads theme zip when data is available', async () => { + it('downloads theme zip when data is available (server theme)', async () => { eXeLearning.app.api.getThemeZip.mockResolvedValue({ zipFileName: 'theme.zip', zipBase64: 'dGVzdA==', }); const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); - await navbarStyles.downloadThemeZip({ dirName: 'user-1' }); + await navbarStyles.downloadThemeZip({ dirName: 'base-1', downloadable: '1' }); expect(clickSpy).toHaveBeenCalled(); clickSpy.mockRestore(); }); + it('shows alert when theme is not downloadable', async () => { + const alertSpy = vi.spyOn(navbarStyles, 'showElementAlert'); + await navbarStyles.downloadThemeZip({ dirName: 'user-1', downloadable: '0' }); + expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('cannot be downloaded'), expect.any(Object)); + expect(eXeLearning.app.api.getThemeZip).not.toHaveBeenCalled(); + }); + + it('downloads user theme from IndexedDB', async () => { + const mockResourceCache = { + getUserThemeRaw: vi.fn().mockResolvedValue({ + compressedFiles: new Uint8Array([80, 75, 3, 4]), // ZIP magic bytes + }), + }; + eXeLearning.app.project._yjsBridge = { + resourceCache: mockResourceCache, + }; + + const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test'); + const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + + await navbarStyles.downloadThemeZip({ + name: 'user-theme', + type: 'user', + downloadable: '1', + }); + + expect(mockResourceCache.getUserThemeRaw).toHaveBeenCalledWith('user-theme'); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + expect(revokeObjectURLSpy).toHaveBeenCalled(); + + createObjectURLSpy.mockRestore(); + revokeObjectURLSpy.mockRestore(); + clickSpy.mockRestore(); + }); + + it('shows alert when user theme not found in IndexedDB', async () => { + const mockResourceCache = { + getUserThemeRaw: vi.fn().mockResolvedValue(null), + }; + eXeLearning.app.project._yjsBridge = { + resourceCache: mockResourceCache, + }; + + const alertSpy = vi.spyOn(navbarStyles, 'showElementAlert'); + await navbarStyles.downloadThemeZip({ + name: 'missing-theme', + type: 'user', + downloadable: '1', + }); + + expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('not found'), expect.any(Object)); + }); + + describe('makeMenuThemeDownload', () => { + it('shows enabled download button when downloadable is 1', () => { + const theme = { downloadable: '1' }; + const li = navbarStyles.makeMenuThemeDownload(theme); + expect(li.classList.contains('disabled')).toBe(false); + expect(li.querySelector('.download-icon-green')).toBeTruthy(); + }); + + it('shows disabled download button when downloadable is not 1', () => { + const theme = { downloadable: '0' }; + const li = navbarStyles.makeMenuThemeDownload(theme); + expect(li.classList.contains('disabled')).toBe(true); + expect(li.querySelector('.download-icon-disabled')).toBeTruthy(); + }); + + it('does not call downloadThemeZip when disabled', async () => { + const theme = { downloadable: '0' }; + const downloadSpy = vi.spyOn(navbarStyles, 'downloadThemeZip'); + const li = navbarStyles.makeMenuThemeDownload(theme); + li.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(downloadSpy).not.toHaveBeenCalled(); + }); + }); + it('toggles sidenav state', () => { const sidenav = document.getElementById('stylessidenav'); const overlay = document.getElementById('sidenav-overlay'); @@ -474,4 +584,228 @@ describe('NavbarStyles', () => { expect(editSpy).toHaveBeenCalled(); expect(buildSpy).toHaveBeenCalled(); }); + + describe('uploadThemeToIndexedDB', () => { + let mockResourceCache; + let mockResourceFetcher; + + beforeEach(() => { + mockResourceCache = { + setUserTheme: vi.fn().mockResolvedValue(), + }; + mockResourceFetcher = { + setUserThemeFiles: vi.fn().mockResolvedValue(), + }; + eXeLearning.app.project._yjsBridge = { + resourceCache: mockResourceCache, + }; + eXeLearning.app.resourceFetcher = mockResourceFetcher; + eXeLearning.app.themes.list.addUserTheme = vi.fn(); + + // Mock fflate + window.fflate = { + unzipSync: vi.fn().mockReturnValue({ + 'config.xml': new TextEncoder().encode('Test Theme1.0'), + 'style.css': new Uint8Array([1, 2, 3]), + }), + zipSync: vi.fn().mockReturnValue(new Uint8Array([80, 75, 3, 4])), + }; + }); + + afterEach(() => { + delete window.fflate; + delete eXeLearning.app.project._yjsBridge; + delete eXeLearning.app.resourceFetcher; + }); + + it('parses ZIP and stores theme in IndexedDB', async () => { + const arrayBuffer = new ArrayBuffer(10); + await navbarStyles.uploadThemeToIndexedDB('theme.zip', arrayBuffer); + + expect(window.fflate.unzipSync).toHaveBeenCalled(); + expect(mockResourceCache.setUserTheme).toHaveBeenCalledWith( + 'test_theme', + expect.any(Uint8Array), + expect.objectContaining({ + name: 'test_theme', + type: 'user', + isUserTheme: true, + }) + ); + expect(mockResourceFetcher.setUserThemeFiles).toHaveBeenCalledWith( + 'test_theme', + expect.any(Object) + ); + expect(eXeLearning.app.themes.list.addUserTheme).toHaveBeenCalled(); + }); + + it('shows alert when fflate is not loaded', async () => { + delete window.fflate; + const alertSpy = vi.spyOn(navbarStyles, 'showElementAlert'); + + await navbarStyles.uploadThemeToIndexedDB('theme.zip', new ArrayBuffer(10)); + + expect(alertSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to install'), + expect.objectContaining({ error: 'fflate library not loaded' }) + ); + }); + + it('shows alert when config.xml is missing', async () => { + window.fflate.unzipSync.mockReturnValue({ + 'style.css': new Uint8Array([1, 2, 3]), + }); + const alertSpy = vi.spyOn(navbarStyles, 'showElementAlert'); + + await navbarStyles.uploadThemeToIndexedDB('theme.zip', new ArrayBuffer(10)); + + expect(alertSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid style'), + expect.any(Object) + ); + }); + + it('shows alert when theme already exists', async () => { + eXeLearning.app.themes.list.installed['test_theme'] = { id: 'test_theme' }; + const alertSpy = vi.spyOn(navbarStyles, 'showElementAlert'); + + await navbarStyles.uploadThemeToIndexedDB('theme.zip', new ArrayBuffer(10)); + + expect(alertSpy).toHaveBeenCalledWith( + expect.stringContaining('already exists'), + expect.any(Object) + ); + }); + + it('shows alert when storage is not available', async () => { + delete eXeLearning.app.project._yjsBridge; + const alertSpy = vi.spyOn(navbarStyles, 'showElementAlert'); + + await navbarStyles.uploadThemeToIndexedDB('theme.zip', new ArrayBuffer(10)); + + expect(alertSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to install'), + expect.objectContaining({ error: expect.stringContaining('Storage not available') }) + ); + }); + + it('detects CSS and JS files in theme', async () => { + window.fflate.unzipSync.mockReturnValue({ + 'config.xml': new TextEncoder().encode('CSS Theme'), + 'main.css': new Uint8Array([1]), + 'extra.css': new Uint8Array([2]), + 'script.js': new Uint8Array([3]), + 'icons/icon1.png': new Uint8Array([4]), + }); + + await navbarStyles.uploadThemeToIndexedDB('theme.zip', new ArrayBuffer(10)); + + expect(mockResourceCache.setUserTheme).toHaveBeenCalledWith( + 'css_theme', + expect.any(Uint8Array), + expect.objectContaining({ + cssFiles: ['main.css', 'extra.css'], + js: ['script.js'], + icons: { icon1: 'icons/icon1.png' }, + }) + ); + }); + }); + + describe('removeTheme for user themes', () => { + let mockResourceCache; + let mockResourceFetcher; + + beforeEach(() => { + mockResourceCache = { + deleteUserTheme: vi.fn().mockResolvedValue(), + }; + mockResourceFetcher = { + userThemeFiles: new Map(), + cache: new Map(), + }; + eXeLearning.app.project._yjsBridge = { + resourceCache: mockResourceCache, + }; + eXeLearning.app.resourceFetcher = mockResourceFetcher; + + // Ensure the user-1 theme exists and is marked as user theme + eXeLearning.app.themes.list.installed['user-1'] = { + id: 'user-1', + type: 'user', + name: 'User Theme 1', + title: 'User 1', + manager: { selected: { name: 'User Theme 1' } }, + dirName: 'user-1', + isUserTheme: true, + }; + }); + + afterEach(() => { + delete eXeLearning.app.project._yjsBridge; + delete eXeLearning.app.resourceFetcher; + }); + + it('removes user theme from IndexedDB and caches', async () => { + mockResourceFetcher.userThemeFiles.set('user-1', {}); + mockResourceFetcher.cache.set('theme:user-1', new Map()); + + const buildSpy = vi.spyOn(navbarStyles, 'buildUserListThemes'); + await navbarStyles.removeTheme('user-1'); + + expect(mockResourceCache.deleteUserTheme).toHaveBeenCalledWith('user-1'); + expect(mockResourceFetcher.userThemeFiles.has('user-1')).toBe(false); + expect(mockResourceFetcher.cache.has('theme:user-1')).toBe(false); + expect(eXeLearning.app.themes.list.removeTheme).toHaveBeenCalledWith('user-1'); + expect(buildSpy).toHaveBeenCalled(); + }); + + it('handles removal errors gracefully', async () => { + mockResourceCache.deleteUserTheme.mockRejectedValue(new Error('DB error')); + + const alertSpy = vi.spyOn(navbarStyles, 'showElementAlert'); + await navbarStyles.removeTheme('user-1'); + + expect(alertSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to remove'), + expect.objectContaining({ error: 'DB error' }) + ); + }); + }); + + describe('addNewReader', () => { + it('reads file as ArrayBuffer and calls uploadThemeToIndexedDB', async () => { + const uploadSpy = vi.spyOn(navbarStyles, 'uploadThemeToIndexedDB').mockResolvedValue(); + const OriginalFileReader = global.FileReader; + + let onloadCallback; + class MockFileReader { + constructor() { + navbarStyles.readers.push(this); + } + readAsArrayBuffer(file) { + setTimeout(() => { + if (onloadCallback) { + onloadCallback({ target: { result: new ArrayBuffer(10) } }); + } + }, 0); + } + set onload(cb) { + onloadCallback = cb; + } + } + global.FileReader = MockFileReader; + + const file = new File(['content'], 'theme.zip', { type: 'application/zip' }); + navbarStyles.addNewReader(file); + + // Wait for async operation + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(uploadSpy).toHaveBeenCalledWith('theme.zip', expect.any(ArrayBuffer)); + + global.FileReader = OriginalFileReader; + uploadSpy.mockRestore(); + }); + }); }); diff --git a/public/app/workarea/menus/navbar/items/navbarUtilities.js b/public/app/workarea/menus/navbar/items/navbarUtilities.js index b33155e39..01849d284 100644 --- a/public/app/workarea/menus/navbar/items/navbarUtilities.js +++ b/public/app/workarea/menus/navbar/items/navbarUtilities.js @@ -558,8 +558,8 @@ export default class NavbarFile { // Build preview options const previewOptions = { baseUrl: window.location.origin, - basePath: eXeLearning.app.config?.basePath || '', - version: eXeLearning.app.config?.version || 'v1', + basePath: window.eXeLearning?.config?.basePath || '', + version: window.eXeLearning?.config?.version || 'v1.0.0', }; // Generate preview using SharedExporters (unified TypeScript pipeline) diff --git a/public/app/workarea/modals/modals/pages/modalOpenUserOdeFiles.js b/public/app/workarea/modals/modals/pages/modalOpenUserOdeFiles.js index 58a35fb61..35af15639 100644 --- a/public/app/workarea/modals/modals/pages/modalOpenUserOdeFiles.js +++ b/public/app/workarea/modals/modals/pages/modalOpenUserOdeFiles.js @@ -1399,6 +1399,31 @@ export default class modalOpenUserOdeFiles extends Modal { try { progressModal.setProcessingPhase('extracting'); + // Static mode: skip API call and use ElpxImporter directly + // Note: Only trigger static mode if capabilities are available AND remote is explicitly false + const capabilities = eXeLearning?.app?.capabilities; + if (capabilities && !capabilities.storage.remote) { + progressModal.hide(); + this.cleanupOrphanedBackdrops(); + + // Use YjsBridge.importFromElpx directly (client-side, no server APIs) + const yjsBridge = eXeLearning.app.project._yjsBridge; + if (!yjsBridge) { + throw new Error('Yjs bridge not initialized.'); + } + + Logger.log('[OpenFile] Static mode - importing file:', odeFileName); + await yjsBridge.importFromElpx(odeFile); + + // Refresh UI after import (without server calls) + if (eXeLearning.app.project?.refreshAfterDirectImport) { + await eXeLearning.app.project.refreshAfterDirectImport(); + } + + Logger.log('[OpenFile] Static mode import complete:', odeFileName); + return; + } + // Create a new project via API to get UUID const projectTitle = odeFileName.replace(/\.(elp|elpx)$/i, '') || 'Imported Project'; const basePath = window.eXeLearning?.config?.basePath || ''; diff --git a/public/app/workarea/project/idevices/content/ideviceNode.js b/public/app/workarea/project/idevices/content/ideviceNode.js index b3cf3912f..5cdc8ca08 100644 --- a/public/app/workarea/project/idevices/content/ideviceNode.js +++ b/public/app/workarea/project/idevices/content/ideviceNode.js @@ -51,10 +51,9 @@ export default class IdeviceNode { this.checkDeviceLoadInterval = null; // Time (ms) of loop this.interval = 100; - // Number of loops - this.checkDeviceLoadNumMax = Math.round( - this.engine.clientCallWaitingTime / this.interval - ); + // Number of loops (default to 5000ms / 100ms = 50 iterations if not configured) + const waitingTime = this.engine.clientCallWaitingTime || 5000; + this.checkDeviceLoadNumMax = Math.round(waitingTime / this.interval); // Check if is valid this.checkIsValid(); @@ -1429,6 +1428,7 @@ export default class IdeviceNode { break; case 'export': this.restartExeIdeviceValue(); + await this.loadExportIdevice(); await this.ideviceInitExport(); break; } @@ -1439,6 +1439,16 @@ export default class IdeviceNode { }, 100); } + /** + * Load export scripts and styles for this iDevice + * Similar to loadEditionIdevice() but for export mode + */ + async loadExportIdevice() { + // Load idevice export files (scripts and styles) + this.loadScriptsExport(); + await this.loadStylesExport(); + } + /********************************* * EDITION */ @@ -1716,9 +1726,31 @@ export default class IdeviceNode { response = await this.exportProcessIdeviceHtml(); break; } + + // Typeset LaTeX in iDevice content after loading + this.typesetLatexInContent(); + return response; } + /** + * Typeset LaTeX formulas in the iDevice content using MathJax + * Called after content is loaded into the DOM + */ + typesetLatexInContent() { + if (!this.ideviceBody) return; + + // Check if content contains LaTeX delimiters + const content = this.ideviceBody.textContent || ''; + if (/(?:\\\(|\\\[|\\begin\{|\$\$)/.test(content)) { + if (typeof MathJax !== 'undefined' && MathJax.typesetPromise) { + MathJax.typesetPromise([this.ideviceBody]).catch(err => { + Logger.log('[IdeviceNode] MathJax typeset error:', err); + }); + } + } + } + /** * Export process of idevice html * In html type idevices just assign the html saved of the idevice to the body diff --git a/public/app/workarea/project/idevices/content/ideviceNode.test.js b/public/app/workarea/project/idevices/content/ideviceNode.test.js index f461e1d3b..11703bd19 100644 --- a/public/app/workarea/project/idevices/content/ideviceNode.test.js +++ b/public/app/workarea/project/idevices/content/ideviceNode.test.js @@ -1992,6 +1992,76 @@ describe('IdeviceNode', () => { expect(spy).toHaveBeenCalled(); }); + + it('calls typesetLatexInContent after loading content', async () => { + idevice.idevice = { componentType: 'html' }; + vi.spyOn(idevice, 'exportProcessIdeviceHtml').mockResolvedValue({ init: 'true' }); + const typesetSpy = vi.spyOn(idevice, 'typesetLatexInContent'); + + await idevice.generateContentExportView(); + + expect(typesetSpy).toHaveBeenCalled(); + }); + }); + + describe('typesetLatexInContent', () => { + beforeEach(() => { + idevice.ideviceBody = document.createElement('div'); + }); + + it('does nothing if ideviceBody is null', () => { + idevice.ideviceBody = null; + idevice.typesetLatexInContent(); + // Should not throw + }); + + it('calls MathJax.typesetPromise when content contains LaTeX delimiters \\(', () => { + const mockTypesetPromise = vi.fn().mockResolvedValue(); + globalThis.MathJax = { typesetPromise: mockTypesetPromise }; + idevice.ideviceBody.textContent = 'Some text with \\(E=mc^2\\) formula'; + + idevice.typesetLatexInContent(); + + expect(mockTypesetPromise).toHaveBeenCalledWith([idevice.ideviceBody]); + }); + + it('calls MathJax.typesetPromise when content contains LaTeX delimiters \\[', () => { + const mockTypesetPromise = vi.fn().mockResolvedValue(); + globalThis.MathJax = { typesetPromise: mockTypesetPromise }; + idevice.ideviceBody.textContent = 'Display math: \\[x^2\\]'; + + idevice.typesetLatexInContent(); + + expect(mockTypesetPromise).toHaveBeenCalledWith([idevice.ideviceBody]); + }); + + it('calls MathJax.typesetPromise when content contains $$', () => { + const mockTypesetPromise = vi.fn().mockResolvedValue(); + globalThis.MathJax = { typesetPromise: mockTypesetPromise }; + idevice.ideviceBody.textContent = 'Math: $$x = 1$$'; + + idevice.typesetLatexInContent(); + + expect(mockTypesetPromise).toHaveBeenCalledWith([idevice.ideviceBody]); + }); + + it('does not call MathJax when content has no LaTeX', () => { + const mockTypesetPromise = vi.fn().mockResolvedValue(); + globalThis.MathJax = { typesetPromise: mockTypesetPromise }; + idevice.ideviceBody.textContent = 'Plain text without formulas'; + + idevice.typesetLatexInContent(); + + expect(mockTypesetPromise).not.toHaveBeenCalled(); + }); + + it('does not call MathJax when MathJax is not defined', () => { + delete globalThis.MathJax; + idevice.ideviceBody.textContent = 'Some text with \\(E=mc^2\\) formula'; + + // Should not throw + idevice.typesetLatexInContent(); + }); }); describe('exportProcessIdeviceHtml', () => { diff --git a/public/app/workarea/project/idevices/idevicesEngine.js b/public/app/workarea/project/idevices/idevicesEngine.js index 3972bba57..11479e94f 100644 --- a/public/app/workarea/project/idevices/idevicesEngine.js +++ b/public/app/workarea/project/idevices/idevicesEngine.js @@ -1860,8 +1860,11 @@ export default class IdevicesEngine { }; } } - // Fallback: try to get from structure node - const node = project?.structure?.getNodeById(pageIdOrProperties); + // Fallback: try to get from structure node (if getNodeById method exists) + const node = + typeof project?.structure?.getNodeById === 'function' + ? project.structure.getNodeById(pageIdOrProperties) + : null; if (node?.properties) { return { hidePageTitle: node.properties.hidePageTitle?.value, @@ -2005,8 +2008,9 @@ export default class IdevicesEngine { * @param {Object} components */ async loadComponentsPage(pagStructure) { - // Load components - pagStructure.forEach(async (block) => { + // Load components - use for...of to properly await async operations + // (forEach with async callbacks doesn't wait for completion) + for (const block of pagStructure) { // Create block let blockNode = this.newBlockNode(block, true); let blockContent = blockNode.blockContent; @@ -2014,15 +2018,15 @@ export default class IdevicesEngine { blockContent.classList.add('loading'); // Add block element to node container this.nodeContentElement.append(blockContent); - // Load Idevices in block - await block.odeComponentsSyncs.forEach(async (idevice) => { + // Load Idevices in block - await each iDevice creation + for (const idevice of block.odeComponentsSyncs) { idevice.mode = 'export'; let ideviceNode = await this.createIdeviceInContent( idevice, blockContent ); - }); - }); + } + } } /** @@ -2516,8 +2520,12 @@ export default class IdevicesEngine { status == 'edition' ? idevice.pathEdition : idevice.pathExport; // Get css let cssText = await eXeLearning.app.api.func.getText(path); - // Replace idevice style urls - cssText = cssText.replace(/url\((?:(?!http))/gm, `url(${idevicePath}`); + // Rewrite relative URLs to absolute, preserving quotes + // Skip absolute URLs (http:, https:, data:, blob:) and root-relative paths (/) + cssText = cssText.replace( + /url\(\s*(['"]?)(?!data:|http:|https:|blob:|\/)([^'")]+)\1\s*\)/g, + (match, quote, path) => `url(${quote}${idevicePath}${path}${quote})` + ); style.innerHTML = cssText; document.querySelector('head').append(style); return style; diff --git a/public/app/workarea/project/idevices/idevicesEngine.test.js b/public/app/workarea/project/idevices/idevicesEngine.test.js index cec0bde22..9290d91a7 100644 --- a/public/app/workarea/project/idevices/idevicesEngine.test.js +++ b/public/app/workarea/project/idevices/idevicesEngine.test.js @@ -104,7 +104,14 @@ global.eXeLearning = { idevices: { getIdeviceInstalled: vi.fn((name) => { if (name === 'text') { - return { name: 'text', title: 'Text', cssClass: 'text', edition: true }; + return { + name: 'text', + title: 'Text', + cssClass: 'text', + edition: true, + loadScriptsExport: vi.fn(() => []), + loadStylesExport: vi.fn().mockResolvedValue([]), + }; } return null; }), @@ -1824,6 +1831,144 @@ describe('IdevicesEngine', () => { expect(style.getAttribute('status')).toBe('edition'); }); + + describe('CSS URL rewriting', () => { + it('rewrites relative URLs without quotes', async () => { + const idevice = { id: 'text', pathEdition: '/path/edition/', pathExport: 'http://localhost/export/' }; + eXeLearning.app.api.func.getText = vi.fn().mockResolvedValue('.icon { background: url(icon.svg); }'); + + const style = await engine.loadStyleByInsertingIt('/path/to/style.css', idevice, 'export'); + + expect(style.innerHTML).toBe('.icon { background: url(http://localhost/export/icon.svg); }'); + }); + + it('rewrites relative URLs with single quotes', async () => { + const idevice = { id: 'text', pathEdition: '/path/edition/', pathExport: 'http://localhost/export/' }; + eXeLearning.app.api.func.getText = vi.fn().mockResolvedValue(".icon { background: url('icon.svg'); }"); + + const style = await engine.loadStyleByInsertingIt('/path/to/style.css', idevice, 'export'); + + expect(style.innerHTML).toBe(".icon { background: url('http://localhost/export/icon.svg'); }"); + }); + + it('rewrites relative URLs with double quotes', async () => { + const idevice = { id: 'text', pathEdition: '/path/edition/', pathExport: 'http://localhost/export/' }; + eXeLearning.app.api.func.getText = vi.fn().mockResolvedValue('.icon { background: url("icon.svg"); }'); + + const style = await engine.loadStyleByInsertingIt('/path/to/style.css', idevice, 'export'); + + expect(style.innerHTML).toBe('.icon { background: url("http://localhost/export/icon.svg"); }'); + }); + + it('rewrites paths with subdirectories', async () => { + const idevice = { id: 'text', pathEdition: '/path/edition/', pathExport: 'http://localhost/export/' }; + eXeLearning.app.api.func.getText = vi.fn().mockResolvedValue('.icon { background: url(images/icons/icon.svg); }'); + + const style = await engine.loadStyleByInsertingIt('/path/to/style.css', idevice, 'export'); + + expect(style.innerHTML).toBe('.icon { background: url(http://localhost/export/images/icons/icon.svg); }'); + }); + + it('does not rewrite absolute HTTP URLs', async () => { + const idevice = { id: 'text', pathEdition: '/path/edition/', pathExport: 'http://localhost/export/' }; + const cssWithHttp = '.icon { background: url(http://example.com/icon.svg); }'; + eXeLearning.app.api.func.getText = vi.fn().mockResolvedValue(cssWithHttp); + + const style = await engine.loadStyleByInsertingIt('/path/to/style.css', idevice, 'export'); + + expect(style.innerHTML).toBe(cssWithHttp); + }); + + it('does not rewrite absolute HTTPS URLs', async () => { + const idevice = { id: 'text', pathEdition: '/path/edition/', pathExport: 'http://localhost/export/' }; + const cssWithHttps = '.icon { background: url(https://example.com/icon.svg); }'; + eXeLearning.app.api.func.getText = vi.fn().mockResolvedValue(cssWithHttps); + + const style = await engine.loadStyleByInsertingIt('/path/to/style.css', idevice, 'export'); + + expect(style.innerHTML).toBe(cssWithHttps); + }); + + it('does not rewrite data URLs', async () => { + const idevice = { id: 'text', pathEdition: '/path/edition/', pathExport: 'http://localhost/export/' }; + const cssWithDataUrl = '.icon { background: url(data:image/svg+xml;base64,PHN2Zz4=); }'; + eXeLearning.app.api.func.getText = vi.fn().mockResolvedValue(cssWithDataUrl); + + const style = await engine.loadStyleByInsertingIt('/path/to/style.css', idevice, 'export'); + + expect(style.innerHTML).toBe(cssWithDataUrl); + }); + + it('does not rewrite blob URLs', async () => { + const idevice = { id: 'text', pathEdition: '/path/edition/', pathExport: 'http://localhost/export/' }; + const cssWithBlobUrl = '.icon { background: url(blob:http://localhost/abc-123); }'; + eXeLearning.app.api.func.getText = vi.fn().mockResolvedValue(cssWithBlobUrl); + + const style = await engine.loadStyleByInsertingIt('/path/to/style.css', idevice, 'export'); + + expect(style.innerHTML).toBe(cssWithBlobUrl); + }); + + it('does not rewrite root-relative URLs (starting with /)', async () => { + const idevice = { id: 'text', pathEdition: '/path/edition/', pathExport: 'http://localhost/export/' }; + // This is the key test case - URLs rewritten by server to API endpoints start with / + const cssWithRootRelative = '.icon { background: url(/api/idevices/download-file-resources?resource=icon.svg); }'; + eXeLearning.app.api.func.getText = vi.fn().mockResolvedValue(cssWithRootRelative); + + const style = await engine.loadStyleByInsertingIt('/path/to/style.css', idevice, 'export'); + + // Should NOT be rewritten - URL already has server path + expect(style.innerHTML).toBe(cssWithRootRelative); + }); + + it('does not rewrite root-relative URLs with quotes', async () => { + const idevice = { id: 'text', pathEdition: '/path/edition/', pathExport: 'http://localhost/export/' }; + const cssWithQuotedRootRelative = ".icon { background: url('/api/idevices/resource.svg'); }"; + eXeLearning.app.api.func.getText = vi.fn().mockResolvedValue(cssWithQuotedRootRelative); + + const style = await engine.loadStyleByInsertingIt('/path/to/style.css', idevice, 'export'); + + expect(style.innerHTML).toBe(cssWithQuotedRootRelative); + }); + + it('handles multiple URLs in CSS', async () => { + const idevice = { id: 'text', pathEdition: '/path/edition/', pathExport: 'http://localhost/export/' }; + const cssWithMultipleUrls = ` + .icon1 { background: url(icon1.svg); } + .icon2 { background: url('icon2.png'); } + .icon3 { background: url("icon3.gif"); } + .external { background: url(https://cdn.example.com/external.png); } + .api { background: url(/api/resource); } + `; + eXeLearning.app.api.func.getText = vi.fn().mockResolvedValue(cssWithMultipleUrls); + + const style = await engine.loadStyleByInsertingIt('/path/to/style.css', idevice, 'export'); + + expect(style.innerHTML).toContain('url(http://localhost/export/icon1.svg)'); + expect(style.innerHTML).toContain("url('http://localhost/export/icon2.png')"); + expect(style.innerHTML).toContain('url("http://localhost/export/icon3.gif")'); + expect(style.innerHTML).toContain('url(https://cdn.example.com/external.png)'); + expect(style.innerHTML).toContain('url(/api/resource)'); + }); + + it('uses pathEdition for URL rewriting when status is edition', async () => { + const idevice = { id: 'text', pathEdition: 'http://localhost/edition/', pathExport: 'http://localhost/export/' }; + eXeLearning.app.api.func.getText = vi.fn().mockResolvedValue('.icon { background: url(icon.svg); }'); + + const style = await engine.loadStyleByInsertingIt('/path/to/style.css', idevice, 'edition'); + + expect(style.innerHTML).toBe('.icon { background: url(http://localhost/edition/icon.svg); }'); + }); + + it('handles URLs with leading spaces', async () => { + const idevice = { id: 'text', pathEdition: '/path/edition/', pathExport: 'http://localhost/export/' }; + eXeLearning.app.api.func.getText = vi.fn().mockResolvedValue('.icon { background: url( icon.svg); }'); + + const style = await engine.loadStyleByInsertingIt('/path/to/style.css', idevice, 'export'); + + expect(style.innerHTML).toBe('.icon { background: url(http://localhost/export/icon.svg); }'); + }); + }); }); describe('renderRemoteIdevice', () => { @@ -1833,7 +1978,11 @@ describe('IdevicesEngine', () => { return null; }); eXeLearning.app.idevices = { - getIdeviceInstalled: vi.fn(() => ({ id: 'text' })), + getIdeviceInstalled: vi.fn(() => ({ + id: 'text', + loadScriptsExport: vi.fn(() => []), + loadStylesExport: vi.fn().mockResolvedValue([]), + })), }; }); @@ -2535,6 +2684,8 @@ describe('IdevicesEngine', () => { name: 'text', title: 'Text', cssClass: 'text', + loadScriptsExport: vi.fn(() => []), + loadStylesExport: vi.fn().mockResolvedValue([]), })), }; vi.spyOn(engine, 'newBlockNode').mockReturnValue({ diff --git a/public/app/workarea/project/projectManager.js b/public/app/workarea/project/projectManager.js index 766bff309..eadd7a88a 100644 --- a/public/app/workarea/project/projectManager.js +++ b/public/app/workarea/project/projectManager.js @@ -68,6 +68,18 @@ export default class projectManager { await this.initialiceProject(); // Show workarea of app this.showScreen(); + + // Static mode: check for pending file import + const capabilities = this.app?.capabilities; + if (!capabilities?.storage?.remote && window.__pendingImportFile) { + Logger.log('[ProjectManager] Found pending import file, importing...'); + const file = window.__pendingImportFile; + window.__pendingImportFile = null; // Clear it + this.importElp(file).catch(err => { + console.error('[ProjectManager] Failed to import pending file:', err); + }); + } + // Call the function to execute sorting and reordering //this.sortBlocksById(true); // Set offline atributtes @@ -1360,18 +1372,27 @@ export default class projectManager { /** * Set installation type attribute to body and elements - * + * Uses RuntimeConfig to differentiate between 'static', 'electron', and 'server' modes */ setInstallationTypeAttribute() { - if (this.offlineInstallation == true) { - document - .querySelector('body') - .setAttribute('installation-type', 'offline'); - /* To review (see #432) - document.querySelector( - '#navbar-button-download-project', - ).innerHTML = 'Save'; - */ + const runtimeConfig = this.app.runtimeConfig; + let installationType; + + if (runtimeConfig?.isStaticMode()) { + installationType = 'static'; + } else if (runtimeConfig?.isElectronMode()) { + installationType = 'electron'; + } else if (this.offlineInstallation === true) { + // Fallback for legacy offline detection (shouldn't reach here normally) + installationType = 'electron'; + } else { + installationType = 'online'; + } + + document.querySelector('body').setAttribute('installation-type', installationType); + + // Offline/Static mode UI adjustments (save button label) + if (installationType === 'electron' || installationType === 'static') { document.querySelector('#head-top-download-button').innerHTML = 'save'; document @@ -1379,17 +1400,13 @@ export default class projectManager { .setAttribute('title', _('Save')); // Expose a stable project key for Electron (per-project save path) - try { - window.__currentProjectId = this.odeId || 'default'; - } catch (e) { - // Intentional: Electron global assignment may fail in browser + if (installationType === 'electron') { + try { + window.__currentProjectId = this.odeId || 'default'; + } catch (e) { + // Intentional: Electron global assignment may fail in browser + } } - - // Offline Save As is now provided by a dedicated menu item - } else { - document - .querySelector('body') - .setAttribute('installation-type', 'online'); } } diff --git a/public/app/workarea/project/projectManager.test.js b/public/app/workarea/project/projectManager.test.js index e85852e2c..345636856 100644 --- a/public/app/workarea/project/projectManager.test.js +++ b/public/app/workarea/project/projectManager.test.js @@ -282,20 +282,43 @@ describe('ProjectManager', () => { describe('helper methods', () => { - it('marks the installation as offline and exposes the project key', () => { - projectManager.offlineInstallation = true; + it('marks the installation as electron and exposes the project key', () => { + projectManager.app.runtimeConfig = { + isStaticMode: () => false, + isElectronMode: () => true, + }; projectManager.odeId = 'custom-project'; const button = document.querySelector('#head-top-download-button'); projectManager.setInstallationTypeAttribute(); - expect(document.body.getAttribute('installation-type')).toBe('offline'); + expect(document.body.getAttribute('installation-type')).toBe('electron'); expect(button.innerHTML).toBe('save'); expect(button.getAttribute('title')).toBe('Save'); expect(window.__currentProjectId).toBe('custom-project'); }); - it('marks the installation as online when the flag is false', () => { + it('marks the installation as static when in static mode', () => { + projectManager.app.runtimeConfig = { + isStaticMode: () => true, + isElectronMode: () => false, + }; + const button = document.querySelector('#head-top-download-button'); + + projectManager.setInstallationTypeAttribute(); + + expect(document.body.getAttribute('installation-type')).toBe('static'); + expect(button.innerHTML).toBe('save'); + expect(button.getAttribute('title')).toBe('Save'); + // Should NOT expose project key for static mode + expect(window.__currentProjectId).toBeUndefined(); + }); + + it('marks the installation as online when in server mode', () => { + projectManager.app.runtimeConfig = { + isStaticMode: () => false, + isElectronMode: () => false, + }; projectManager.offlineInstallation = false; const button = document.querySelector('#head-top-download-button'); @@ -305,6 +328,17 @@ describe('ProjectManager', () => { expect(button.innerHTML).toBe('Download'); }); + it('falls back to electron when offlineInstallation is true and no runtimeConfig', () => { + projectManager.app.runtimeConfig = null; + projectManager.offlineInstallation = true; + const button = document.querySelector('#head-top-download-button'); + + projectManager.setInstallationTypeAttribute(); + + expect(document.body.getAttribute('installation-type')).toBe('electron'); + expect(button.innerHTML).toBe('save'); + }); + it('shows the save confirmation modal', () => { projectManager.showModalSaveOk(); diff --git a/public/app/workarea/project/properties/formProperties.js b/public/app/workarea/project/properties/formProperties.js index 16177f76d..10fdb7e74 100644 --- a/public/app/workarea/project/properties/formProperties.js +++ b/public/app/workarea/project/properties/formProperties.js @@ -179,7 +179,7 @@ export default class FormProperties { groupElementTitle.setAttribute('aria-controls', collapseId); let titleText = - "" + topGroupTitle + ''; + "" + _(topGroupTitle) + ''; const catKey = Object.keys(property.category || { '': '' })[0]; if (catKey == this.cataloguingCategoryKey) { if (property.required) { @@ -377,7 +377,8 @@ export default class FormProperties { makeRowElementLabel(id, property) { const propertyTitle = document.createElement('label'); - let propertyTitleText = property.title; + // Translate the property title + let propertyTitleText = _(property.title); if (property.required) propertyTitleText = '* ' + propertyTitleText; propertyTitle.innerHTML = propertyTitleText; propertyTitle.setAttribute('for', id); @@ -528,7 +529,7 @@ export default class FormProperties { const helpSpanText = document.createElement('span'); helpSpanText.classList.add('help-content', 'help-hidden'); - helpSpanText.innerHTML = property.help; + helpSpanText.innerHTML = _(property.help); helpContainer.append(helpIcon, helpSpanText); return helpContainer; diff --git a/public/app/workarea/project/structure/structureNode.js b/public/app/workarea/project/structure/structureNode.js index 57d6fe374..462a9b22b 100644 --- a/public/app/workarea/project/structure/structureNode.js +++ b/public/app/workarea/project/structure/structureNode.js @@ -47,6 +47,11 @@ export default class StructureNode { * @param {Array} data */ setParams(data) { + // Guard against undefined/null data + if (!data) { + return; + } + for (let [i, param] of Object.entries(this.params)) { let defaultValue = this.default[param] ? this.default[param] : null; this[param] = data[param] ? data[param] : defaultValue; diff --git a/public/app/workarea/themes/theme.js b/public/app/workarea/themes/theme.js index c673fe583..a9ca7f922 100644 --- a/public/app/workarea/themes/theme.js +++ b/public/app/workarea/themes/theme.js @@ -2,6 +2,7 @@ export default class Theme { constructor(manager, data) { this.manager = manager; this.id = data.dirName; + this.dirName = data.dirName; // Store dirName for theme editing this.setConfigValues(data); this.path = `${manager.symfonyURL}${data.url}/`; this.valid = data.valid; @@ -125,9 +126,17 @@ export default class Theme { } /** - * + * Load theme CSS files + * Handles both server themes and user themes (from Yjs storage) */ async loadCss() { + // Check if this is a user theme (stored in Yjs, not on server) + if (this.isUserTheme || this.path.startsWith('user-theme://')) { + await this.loadUserThemeCss(); + return; + } + + // Standard server theme loading for (let i = 0; i < this.cssFiles.length; i++) { let pathCss = this.path + this.cssFiles[i]; await this.loadStyleByInsertingIt( @@ -136,6 +145,105 @@ export default class Theme { } } + /** + * Load CSS for user themes (stored in IndexedDB/ResourceFetcher) + * @private + */ + async loadUserThemeCss() { + const resourceFetcher = eXeLearning.app.resourceFetcher; + if (!resourceFetcher) { + console.error('[Theme] ResourceFetcher not available for user theme'); + return; + } + + // Get theme files from ResourceFetcher (async to support IndexedDB fallback) + let themeFiles = resourceFetcher.getUserTheme(this.id); + if (!themeFiles && resourceFetcher.getUserThemeAsync) { + // Try async method that fetches from IndexedDB + themeFiles = await resourceFetcher.getUserThemeAsync(this.id); + } + + if (!themeFiles) { + console.error(`[Theme] User theme '${this.id}' files not found in ResourceFetcher`); + return; + } + + // Load each CSS file + for (const cssFileName of this.cssFiles) { + const cssBlob = themeFiles.get(cssFileName); + if (cssBlob) { + const cssText = await cssBlob.text(); + await this.injectUserThemeCss(cssText, cssFileName); + } else { + console.warn(`[Theme] CSS file '${cssFileName}' not found in user theme '${this.id}'`); + } + } + } + + /** + * Inject user theme CSS into the page + * @param {string} cssText - CSS content + * @param {string} fileName - CSS file name (for debugging) + * @private + */ + async injectUserThemeCss(cssText, fileName) { + const style = document.createElement('style'); + style.classList.add('exe'); + style.classList.add('theme-style'); + style.setAttribute('data-user-theme', this.id); + style.setAttribute('data-file', fileName); + + // For user themes, we need to convert relative URLs to blob URLs + // This is handled by rewriting url() references to use blob URLs from the theme files + const resourceFetcher = eXeLearning.app.resourceFetcher; + const themeFiles = resourceFetcher?.getUserTheme(this.id); + + if (themeFiles) { + cssText = await this.rewriteCssUrls(cssText, themeFiles); + } + + style.innerHTML = cssText; + document.querySelector('head').append(style); + return style; + } + + /** + * Rewrite CSS url() references to use blob URLs for user theme resources + * @param {string} cssText - Original CSS + * @param {Map} themeFiles - Theme files map + * @returns {Promise} CSS with rewritten URLs + * @private + */ + async rewriteCssUrls(cssText, themeFiles) { + // Find all url() references + const urlRegex = /url\(['"]?([^'")]+)['"]?\)/g; + const matches = [...cssText.matchAll(urlRegex)]; + + // Process each URL reference + for (const match of matches) { + const originalUrl = match[1]; + + // Skip absolute URLs, data URLs, and external URLs + if (originalUrl.startsWith('http') || + originalUrl.startsWith('data:') || + originalUrl.startsWith('//')) { + continue; + } + + // Get the file from theme files + const cleanPath = originalUrl.replace(/^\.\//, ''); + const fileBlob = themeFiles.get(cleanPath); + + if (fileBlob) { + // Create blob URL for the resource + const blobUrl = URL.createObjectURL(fileBlob); + cssText = cssText.replace(match[0], `url('${blobUrl}')`); + } + } + + return cssText; + } + /** * */ @@ -181,15 +289,25 @@ export default class Theme { * @returns {String} */ getResourceServicePath(path) { - // Site themes are served directly from /site-files/themes/ + // Site themes and user themes from FILES_DIR are served directly // No need to go through the idevices download service - if (path.includes('/site-files/') || path.includes('/admin-files/')) { + if (path.includes('/site-files/') || path.includes('/admin-files/') || path.includes('/user-files/')) { return path; } - let pathServiceResources = - this.manager.app.api.endpoints.api_idevices_download_file_resources - .path; + // Static mode: bundled theme files in /files/perm/themes/ are served directly + if (path.includes('/files/perm/themes/')) { + return path; + } + + // Check if endpoint exists (may not exist in static mode) + const endpoint = + this.manager.app.api.endpoints.api_idevices_download_file_resources; + if (!endpoint) { + return path; // Return as-is if no endpoint available + } + + let pathServiceResources = endpoint.path; let pathSplit = path.split('/files/'); let pathParam = pathSplit.length == 2 ? pathSplit[1] : path; pathParam = '/' + pathParam; @@ -230,8 +348,12 @@ export default class Theme { style.classList.add('theme-style'); // Get css let cssText = await eXeLearning.app.api.func.getText(path); - // Replace idevice style urls - cssText = cssText.replace(/url\((?:(?!http))/gm, `url(${this.path}`); + // Rewrite relative URLs to absolute, preserving quotes + // Skip absolute URLs (http:, https:, data:, blob:) and root-relative paths (/) + cssText = cssText.replace( + /url\(\s*(['"]?)(?!data:|http:|https:|blob:|\/)([^'")]+)\1\s*\)/g, + (match, quote, path) => `url(${quote}${this.path}${path}${quote})` + ); style.innerHTML = cssText; document.querySelector('head').append(style); return style; diff --git a/public/app/workarea/themes/theme.test.js b/public/app/workarea/themes/theme.test.js index f39358d0b..6956d01a4 100644 --- a/public/app/workarea/themes/theme.test.js +++ b/public/app/workarea/themes/theme.test.js @@ -94,6 +94,10 @@ describe('Theme', () => { expect(spy).toHaveBeenCalledWith(mockData); spy.mockRestore(); }); + + it('should store dirName property for theme editing', () => { + expect(theme.dirName).toBe('test-theme'); + }); }); describe('setConfigValues', () => { @@ -210,11 +214,13 @@ describe('Theme', () => { expect(result).toContain('resource=//themes/test/style.css'); }); - it('should extract path after /files/', () => { + it('should return static mode theme paths directly', () => { + // Static mode: paths containing /files/perm/themes/ are served directly const path = 'http://localhost/files/perm/themes/modern/base.css'; const result = theme.getResourceServicePath(path); - expect(result).toBe('/api/resources?resource=/perm/themes/modern/base.css'); + // Paths to bundled theme files are returned as-is for static mode + expect(result).toBe('http://localhost/files/perm/themes/modern/base.css'); }); it('should return site theme paths directly without resource service', () => { @@ -232,6 +238,14 @@ describe('Theme', () => { // Admin themes path still works for backwards compatibility expect(result).toBe('/v1.0.0/admin-files/themes/custom-theme/style.css'); }); + + it('should return user theme paths directly (from FILES_DIR)', () => { + const path = '/v0.0.0-alpha/user-files/themes/universal/style.css'; + const result = theme.getResourceServicePath(path); + + // User themes imported from ELP files are served directly via /user-files/ + expect(result).toBe('/v0.0.0-alpha/user-files/themes/universal/style.css'); + }); }); describe('loadStyleDynamically', () => { @@ -328,15 +342,37 @@ describe('Theme', () => { expect(window.eXeLearning.app.api.func.getText).toHaveBeenCalledWith(path); }); - it('should replace relative URLs with theme path', async () => { + it('should replace relative URLs without quotes', async () => { window.eXeLearning.app.api.func.getText.mockResolvedValue( - 'body { background: url(bg.png); }' + 'body { background: url(img/bg.png); }' ); const path = '/api/resources?resource=/themes/test/style.css'; await theme.loadStyleByInsertingIt(path); - expect(mockStyle.innerHTML).toBe('body { background: url(http://localhost:8080/themes/test-theme/bg.png); }'); + expect(mockStyle.innerHTML).toBe('body { background: url(http://localhost:8080/themes/test-theme/img/bg.png); }'); + }); + + it('should replace relative URLs with single quotes', async () => { + window.eXeLearning.app.api.func.getText.mockResolvedValue( + "body { background: url('img/bg.png'); }" + ); + + const path = '/api/resources?resource=/themes/test/style.css'; + await theme.loadStyleByInsertingIt(path); + + expect(mockStyle.innerHTML).toBe("body { background: url('http://localhost:8080/themes/test-theme/img/bg.png'); }"); + }); + + it('should replace relative URLs with double quotes', async () => { + window.eXeLearning.app.api.func.getText.mockResolvedValue( + 'body { background: url("img/bg.png"); }' + ); + + const path = '/api/resources?resource=/themes/test/style.css'; + await theme.loadStyleByInsertingIt(path); + + expect(mockStyle.innerHTML).toBe('body { background: url("http://localhost:8080/themes/test-theme/img/bg.png"); }'); }); it('should not replace absolute HTTP URLs', async () => { @@ -350,6 +386,63 @@ describe('Theme', () => { expect(mockStyle.innerHTML).toBe('body { background: url(http://example.com/bg.png); }'); }); + it('should not replace absolute HTTPS URLs', async () => { + window.eXeLearning.app.api.func.getText.mockResolvedValue( + 'body { background: url(https://example.com/bg.png); }' + ); + + const path = '/api/resources?resource=/themes/test/style.css'; + await theme.loadStyleByInsertingIt(path); + + expect(mockStyle.innerHTML).toBe('body { background: url(https://example.com/bg.png); }'); + }); + + it('should not replace data URLs', async () => { + window.eXeLearning.app.api.func.getText.mockResolvedValue( + 'body { background: url(data:image/png;base64,abc123); }' + ); + + const path = '/api/resources?resource=/themes/test/style.css'; + await theme.loadStyleByInsertingIt(path); + + expect(mockStyle.innerHTML).toBe('body { background: url(data:image/png;base64,abc123); }'); + }); + + it('should not replace blob URLs', async () => { + window.eXeLearning.app.api.func.getText.mockResolvedValue( + 'body { background: url(blob:http://localhost/abc-123); }' + ); + + const path = '/api/resources?resource=/themes/test/style.css'; + await theme.loadStyleByInsertingIt(path); + + expect(mockStyle.innerHTML).toBe('body { background: url(blob:http://localhost/abc-123); }'); + }); + + it('should handle multiple URLs in CSS', async () => { + window.eXeLearning.app.api.func.getText.mockResolvedValue( + '.exe-content { background: url(img/bg.png); } .header { background: url("images/header.jpg"); }' + ); + + const path = '/api/resources?resource=/themes/test/style.css'; + await theme.loadStyleByInsertingIt(path); + + expect(mockStyle.innerHTML).toBe( + '.exe-content { background: url(http://localhost:8080/themes/test-theme/img/bg.png); } .header { background: url("http://localhost:8080/themes/test-theme/images/header.jpg"); }' + ); + }); + + it('should not replace root-relative URLs (starting with /)', async () => { + window.eXeLearning.app.api.func.getText.mockResolvedValue( + 'body { background: url(/api/idevices/download-file-resources); }' + ); + + const path = '/api/resources?resource=/themes/test/style.css'; + await theme.loadStyleByInsertingIt(path); + + expect(mockStyle.innerHTML).toBe('body { background: url(/api/idevices/download-file-resources); }'); + }); + it('should append style to head', async () => { const path = '/api/resources?resource=/themes/test/style.css'; await theme.loadStyleByInsertingIt(path); @@ -415,6 +508,278 @@ describe('Theme', () => { expect(spy).not.toHaveBeenCalled(); }); + + it('should use loadUserThemeCss for user themes (isUserTheme flag)', async () => { + theme.isUserTheme = true; + const spy = vi.spyOn(theme, 'loadUserThemeCss').mockResolvedValue(undefined); + const serverSpy = vi.spyOn(theme, 'loadStyleByInsertingIt'); + + await theme.loadCss(); + + expect(spy).toHaveBeenCalled(); + expect(serverSpy).not.toHaveBeenCalled(); + }); + + it('should use loadUserThemeCss for user-theme:// paths', async () => { + theme.path = 'user-theme://custom-theme/'; + const spy = vi.spyOn(theme, 'loadUserThemeCss').mockResolvedValue(undefined); + const serverSpy = vi.spyOn(theme, 'loadStyleByInsertingIt'); + + await theme.loadCss(); + + expect(spy).toHaveBeenCalled(); + expect(serverSpy).not.toHaveBeenCalled(); + }); + }); + + describe('loadUserThemeCss', () => { + beforeEach(() => { + window.eXeLearning.app.resourceFetcher = null; + }); + + it('should log error when ResourceFetcher is not available', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await theme.loadUserThemeCss(); + + expect(consoleSpy).toHaveBeenCalledWith('[Theme] ResourceFetcher not available for user theme'); + }); + + it('should log error when theme files are not found', async () => { + window.eXeLearning.app.resourceFetcher = { + getUserTheme: vi.fn().mockReturnValue(null), + }; + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await theme.loadUserThemeCss(); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('files not found')); + }); + + it('should try getUserThemeAsync if getUserTheme returns null', async () => { + const mockThemeFiles = new Map([ + ['style.css', new Blob(['body { color: red; }'], { type: 'text/css' })], + ]); + + window.eXeLearning.app.resourceFetcher = { + getUserTheme: vi.fn().mockReturnValue(null), + getUserThemeAsync: vi.fn().mockResolvedValue(mockThemeFiles), + }; + + theme.cssFiles = ['style.css']; + const injectSpy = vi.spyOn(theme, 'injectUserThemeCss').mockResolvedValue({}); + + await theme.loadUserThemeCss(); + + expect(window.eXeLearning.app.resourceFetcher.getUserThemeAsync).toHaveBeenCalledWith('test-theme'); + expect(injectSpy).toHaveBeenCalled(); + }); + + it('should load CSS files from theme files map', async () => { + const mockThemeFiles = new Map([ + ['style.css', new Blob(['body { color: red; }'], { type: 'text/css' })], + ['layout.css', new Blob(['.container { width: 100%; }'], { type: 'text/css' })], + ]); + + window.eXeLearning.app.resourceFetcher = { + getUserTheme: vi.fn().mockReturnValue(mockThemeFiles), + }; + + const injectSpy = vi.spyOn(theme, 'injectUserThemeCss').mockResolvedValue({}); + + await theme.loadUserThemeCss(); + + expect(injectSpy).toHaveBeenCalledTimes(2); + }); + + it('should warn when CSS file not found in theme files', async () => { + const mockThemeFiles = new Map(); // Empty map + + window.eXeLearning.app.resourceFetcher = { + getUserTheme: vi.fn().mockReturnValue(mockThemeFiles), + }; + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await theme.loadUserThemeCss(); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('not found in user theme')); + }); + }); + + describe('injectUserThemeCss', () => { + let mockHead; + let mockStyle; + + beforeEach(() => { + mockStyle = { + classList: { + add: vi.fn(), + }, + setAttribute: vi.fn(), + innerHTML: '', + }; + + mockHead = { + append: vi.fn(), + }; + + vi.spyOn(document, 'querySelector').mockReturnValue(mockHead); + vi.spyOn(document, 'createElement').mockReturnValue(mockStyle); + + window.eXeLearning.app.resourceFetcher = { + getUserTheme: vi.fn().mockReturnValue(new Map()), + }; + }); + + it('should create style element with user theme attributes', async () => { + await theme.injectUserThemeCss('body { color: red; }', 'style.css'); + + expect(mockStyle.classList.add).toHaveBeenCalledWith('exe'); + expect(mockStyle.classList.add).toHaveBeenCalledWith('theme-style'); + expect(mockStyle.setAttribute).toHaveBeenCalledWith('data-user-theme', 'test-theme'); + expect(mockStyle.setAttribute).toHaveBeenCalledWith('data-file', 'style.css'); + }); + + it('should append style to head', async () => { + await theme.injectUserThemeCss('body { color: red; }', 'style.css'); + + expect(mockHead.append).toHaveBeenCalledWith(mockStyle); + }); + + it('should return the created style element', async () => { + const result = await theme.injectUserThemeCss('body { color: red; }', 'style.css'); + + expect(result).toBe(mockStyle); + }); + + it('should call rewriteCssUrls when theme files are available', async () => { + const mockThemeFiles = new Map([ + ['img/bg.png', new Blob([''], { type: 'image/png' })], + ]); + + window.eXeLearning.app.resourceFetcher = { + getUserTheme: vi.fn().mockReturnValue(mockThemeFiles), + }; + + const rewriteSpy = vi.spyOn(theme, 'rewriteCssUrls').mockResolvedValue('rewritten css'); + + await theme.injectUserThemeCss('body { background: url(img/bg.png); }', 'style.css'); + + expect(rewriteSpy).toHaveBeenCalledWith('body { background: url(img/bg.png); }', mockThemeFiles); + }); + }); + + describe('rewriteCssUrls', () => { + let mockCreateObjectURL; + + beforeEach(() => { + mockCreateObjectURL = vi.fn().mockReturnValue('blob:http://localhost/test-blob'); + global.URL.createObjectURL = mockCreateObjectURL; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should rewrite relative URLs to blob URLs', async () => { + const mockThemeFiles = new Map([ + ['img/bg.png', new Blob([''], { type: 'image/png' })], + ]); + + const cssText = 'body { background: url(img/bg.png); }'; + const result = await theme.rewriteCssUrls(cssText, mockThemeFiles); + + expect(mockCreateObjectURL).toHaveBeenCalled(); + expect(result).toContain("url('blob:"); + }); + + it('should handle URLs with ./ prefix', async () => { + const mockThemeFiles = new Map([ + ['img/bg.png', new Blob([''], { type: 'image/png' })], + ]); + + const cssText = 'body { background: url(./img/bg.png); }'; + const result = await theme.rewriteCssUrls(cssText, mockThemeFiles); + + expect(mockCreateObjectURL).toHaveBeenCalled(); + expect(result).toContain("url('blob:"); + }); + + it('should skip http URLs', async () => { + const mockThemeFiles = new Map(); + + const cssText = 'body { background: url(http://example.com/bg.png); }'; + const result = await theme.rewriteCssUrls(cssText, mockThemeFiles); + + expect(mockCreateObjectURL).not.toHaveBeenCalled(); + expect(result).toBe(cssText); + }); + + it('should skip https URLs', async () => { + const mockThemeFiles = new Map(); + + const cssText = 'body { background: url(https://example.com/bg.png); }'; + const result = await theme.rewriteCssUrls(cssText, mockThemeFiles); + + expect(mockCreateObjectURL).not.toHaveBeenCalled(); + expect(result).toBe(cssText); + }); + + it('should skip data URLs', async () => { + const mockThemeFiles = new Map(); + + const cssText = 'body { background: url(data:image/png;base64,abc); }'; + const result = await theme.rewriteCssUrls(cssText, mockThemeFiles); + + expect(mockCreateObjectURL).not.toHaveBeenCalled(); + expect(result).toBe(cssText); + }); + + it('should skip protocol-relative URLs', async () => { + const mockThemeFiles = new Map(); + + const cssText = 'body { background: url(//example.com/bg.png); }'; + const result = await theme.rewriteCssUrls(cssText, mockThemeFiles); + + expect(mockCreateObjectURL).not.toHaveBeenCalled(); + expect(result).toBe(cssText); + }); + + it('should handle missing files gracefully', async () => { + const mockThemeFiles = new Map(); // Empty map + + const cssText = 'body { background: url(img/missing.png); }'; + const result = await theme.rewriteCssUrls(cssText, mockThemeFiles); + + expect(mockCreateObjectURL).not.toHaveBeenCalled(); + expect(result).toBe(cssText); + }); + + it('should handle multiple URLs', async () => { + const mockThemeFiles = new Map([ + ['img/bg.png', new Blob([''], { type: 'image/png' })], + ['fonts/font.woff', new Blob([''], { type: 'font/woff' })], + ]); + + const cssText = 'body { background: url(img/bg.png); } @font-face { src: url(fonts/font.woff); }'; + const result = await theme.rewriteCssUrls(cssText, mockThemeFiles); + + expect(mockCreateObjectURL).toHaveBeenCalledTimes(2); + expect(result).toContain("url('blob:"); + }); + + it('should handle URLs with quotes', async () => { + const mockThemeFiles = new Map([ + ['img/bg.png', new Blob([''], { type: 'image/png' })], + ]); + + const cssText = "body { background: url('img/bg.png'); }"; + const result = await theme.rewriteCssUrls(cssText, mockThemeFiles); + + expect(mockCreateObjectURL).toHaveBeenCalled(); + expect(result).toContain("url('blob:"); + }); }); describe('select', () => { diff --git a/public/app/workarea/themes/themeList.js b/public/app/workarea/themes/themeList.js index 04c421e3e..3e8bbc596 100644 --- a/public/app/workarea/themes/themeList.js +++ b/public/app/workarea/themes/themeList.js @@ -8,10 +8,11 @@ export default class ThemeList { /** * Load themes - * + * Loads both server themes (base/site) and user themes from IndexedDB */ async load() { await this.loadThemesInstalled(); + await this.loadUserThemesFromIndexedDB(); } /** @@ -81,6 +82,82 @@ export default class ThemeList { this.installed[themeData.name] = theme; } + /** + * Load user themes from IndexedDB (persistent local storage) + * These are themes imported from .elpx files that persist across sessions. + * @param {ResourceCache} [providedCache] - Optional ResourceCache to use (passed from YjsProjectBridge during init) + */ + async loadUserThemesFromIndexedDB(providedCache = null) { + try { + // Use provided cache, or try to get from YjsProjectBridge + let resourceCache = providedCache; + if (!resourceCache) { + resourceCache = this.manager.app?.project?._yjsBridge?.resourceCache; + } + if (!resourceCache) { + return; + } + + // List all user themes in IndexedDB + const userThemes = await resourceCache.listUserThemes(); + if (!userThemes || userThemes.length === 0) { + return; + } + + Logger.log(`[ThemeList] Loading ${userThemes.length} user theme(s) from IndexedDB...`); + + for (const { name, config } of userThemes) { + // Skip if already loaded + if (this.installed[name]) { + continue; + } + + // Add to installed list + this.addUserTheme(config); + } + + this.orderThemesInstalled(); + Logger.log('[ThemeList] User themes loaded from IndexedDB'); + } catch (error) { + console.error('[ThemeList] Error loading user themes from IndexedDB:', error); + } + } + + /** + * Add a user theme (imported from .elpx, stored in IndexedDB) + * User themes are stored client-side in IndexedDB for persistence + * and synced via Yjs for collaboration. + * + * @param {Object} themeConfig - Theme configuration from parsed config.xml + * @param {string} themeConfig.name - Theme name + * @param {string} themeConfig.dirName - Theme directory name + * @param {string} themeConfig.displayName - Display name + * @param {string} themeConfig.type - Should be 'user' + * @param {string[]} themeConfig.cssFiles - CSS file names + * @param {boolean} themeConfig.isUserTheme - Flag indicating user theme + * @returns {Theme} The created Theme instance + */ + addUserTheme(themeConfig) { + // User themes need special URL handling since they're served from IndexedDB/Yjs + // Use a special prefix that the Theme class will recognize + const userThemeUrl = `user-theme://${themeConfig.dirName}`; + + const themeData = { + ...themeConfig, + url: userThemeUrl, + preview: '', // No preview for user themes + valid: true, + }; + + const theme = this.newTheme(themeData); + theme.isUserTheme = true; // Mark as user theme + this.installed[themeConfig.name] = theme; + this.orderThemesInstalled(); + + console.log(`[ThemeList] Added user theme '${themeConfig.name}'`); + return theme; + } + /** * Create theme class * diff --git a/public/app/workarea/themes/themeList.test.js b/public/app/workarea/themes/themeList.test.js index 9b351da25..9fe1bf41a 100644 --- a/public/app/workarea/themes/themeList.test.js +++ b/public/app/workarea/themes/themeList.test.js @@ -71,6 +71,16 @@ describe('ThemeList', () => { expect(spy).toHaveBeenCalled(); }); + + it('should call loadUserThemesFromIndexedDB after loadThemesInstalled', async () => { + const installedSpy = vi.spyOn(themeList, 'loadThemesInstalled'); + const userThemesSpy = vi.spyOn(themeList, 'loadUserThemesFromIndexedDB').mockResolvedValue(undefined); + + await themeList.load(); + + expect(installedSpy).toHaveBeenCalled(); + expect(userThemesSpy).toHaveBeenCalled(); + }); }); describe('loadThemesInstalled', () => { @@ -367,6 +377,193 @@ describe('ThemeList', () => { }); }); + describe('loadUserThemesFromIndexedDB', () => { + let mockResourceCache; + + beforeEach(() => { + mockResourceCache = { + listUserThemes: vi.fn(), + }; + }); + + it('should return early if no resourceCache available', async () => { + mockManager.app.project = null; + + await expect(themeList.loadUserThemesFromIndexedDB()).resolves.not.toThrow(); + }); + + it('should return early if no user themes in IndexedDB', async () => { + mockManager.app.project = { + _yjsBridge: { + resourceCache: mockResourceCache, + }, + }; + mockResourceCache.listUserThemes.mockResolvedValue([]); + + const spy = vi.spyOn(themeList, 'addUserTheme'); + await themeList.loadUserThemesFromIndexedDB(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should load user themes from IndexedDB', async () => { + mockManager.app.project = { + _yjsBridge: { + resourceCache: mockResourceCache, + }, + }; + mockResourceCache.listUserThemes.mockResolvedValue([ + { name: 'user-theme-1', config: { name: 'user-theme-1', dirName: 'user-theme-1' } }, + { name: 'user-theme-2', config: { name: 'user-theme-2', dirName: 'user-theme-2' } }, + ]); + + const spy = vi.spyOn(themeList, 'addUserTheme').mockReturnValue({}); + await themeList.loadUserThemesFromIndexedDB(); + + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should use provided cache parameter', async () => { + await themeList.loadUserThemesFromIndexedDB(mockResourceCache); + + expect(mockResourceCache.listUserThemes).toHaveBeenCalled(); + }); + + it('should skip already loaded themes', async () => { + themeList.installed['user-theme-1'] = { id: 'user-theme-1' }; + + mockManager.app.project = { + _yjsBridge: { + resourceCache: mockResourceCache, + }, + }; + mockResourceCache.listUserThemes.mockResolvedValue([ + { name: 'user-theme-1', config: { name: 'user-theme-1' } }, + { name: 'user-theme-2', config: { name: 'user-theme-2', dirName: 'user-theme-2' } }, + ]); + + const spy = vi.spyOn(themeList, 'addUserTheme').mockReturnValue({}); + await themeList.loadUserThemesFromIndexedDB(); + + expect(spy).toHaveBeenCalledTimes(1); // Only user-theme-2 + }); + + it('should handle errors gracefully', async () => { + mockManager.app.project = { + _yjsBridge: { + resourceCache: mockResourceCache, + }, + }; + mockResourceCache.listUserThemes.mockRejectedValue(new Error('DB error')); + + await expect(themeList.loadUserThemesFromIndexedDB()).resolves.not.toThrow(); + }); + + it('should order themes after loading', async () => { + mockManager.app.project = { + _yjsBridge: { + resourceCache: mockResourceCache, + }, + }; + mockResourceCache.listUserThemes.mockResolvedValue([ + { name: 'user-theme', config: { name: 'user-theme', dirName: 'user-theme' } }, + ]); + + vi.spyOn(themeList, 'addUserTheme').mockReturnValue({}); + const orderSpy = vi.spyOn(themeList, 'orderThemesInstalled'); + + await themeList.loadUserThemesFromIndexedDB(); + + expect(orderSpy).toHaveBeenCalled(); + }); + }); + + describe('addUserTheme', () => { + it('should create theme with user-theme:// URL', () => { + const config = { + name: 'my-user-theme', + dirName: 'my-user-theme', + title: 'My User Theme', + cssFiles: ['style.css'], + }; + + themeList.addUserTheme(config); + + expect(Theme).toHaveBeenCalledWith( + mockManager, + expect.objectContaining({ + url: 'user-theme://my-user-theme', + valid: true, + }) + ); + }); + + it('should mark theme as isUserTheme', () => { + const config = { + name: 'my-user-theme', + dirName: 'my-user-theme', + }; + + const result = themeList.addUserTheme(config); + + expect(result.isUserTheme).toBe(true); + }); + + it('should add theme to installed object', () => { + const config = { + name: 'my-user-theme', + dirName: 'my-user-theme', + }; + + themeList.addUserTheme(config); + + expect(themeList.installed['my-user-theme']).toBeDefined(); + }); + + it('should order themes after adding', () => { + const spy = vi.spyOn(themeList, 'orderThemesInstalled'); + + themeList.addUserTheme({ + name: 'my-user-theme', + dirName: 'my-user-theme', + }); + + expect(spy).toHaveBeenCalled(); + }); + + it('should return the created theme', () => { + const result = themeList.addUserTheme({ + name: 'my-user-theme', + dirName: 'my-user-theme', + }); + + expect(result).toBeDefined(); + expect(result.isUserTheme).toBe(true); + }); + + it('should preserve config properties', () => { + const config = { + name: 'my-user-theme', + dirName: 'my-user-theme', + title: 'My Theme Title', + cssFiles: ['style.css', 'layout.css'], + author: 'Test Author', + }; + + themeList.addUserTheme(config); + + expect(Theme).toHaveBeenCalledWith( + mockManager, + expect.objectContaining({ + name: 'my-user-theme', + title: 'My Theme Title', + cssFiles: ['style.css', 'layout.css'], + author: 'Test Author', + }) + ); + }); + }); + describe('integration', () => { it('should load and order themes from API', async () => { await themeList.load(); diff --git a/public/app/workarea/themes/themesManager.js b/public/app/workarea/themes/themesManager.js index 42ded5b4e..9cffb2c87 100644 --- a/public/app/workarea/themes/themesManager.js +++ b/public/app/workarea/themes/themesManager.js @@ -93,6 +93,74 @@ export default class ThemesManager { } } + /** + * Ensure user theme is copied to Yjs themeFiles for collaboration/export + * Only copies if the theme is not already in Yjs + * @param {string} themeId - Theme ID + * @param {Object} theme - Theme instance + * @private + */ + async _ensureUserThemeInYjs(themeId, theme) { + const project = this.app.project; + if (!project?._yjsBridge) return; + + const documentManager = project._yjsBridge.getDocumentManager(); + if (!documentManager) return; + + // Check if theme already exists in Yjs themeFiles + const themeFilesMap = documentManager.getThemeFiles(); + if (themeFilesMap.has(themeId)) { + getLogger().log(`[ThemesManager] User theme '${themeId}' already in Yjs`); + return; + } + + // Get theme files from ResourceCache (IndexedDB) + const resourceCache = project._yjsBridge.resourceCache; + if (!resourceCache) { + console.warn('[ThemesManager] ResourceCache not available for copying theme to Yjs'); + return; + } + + try { + // Get raw compressed data from IndexedDB + const rawTheme = await resourceCache.getUserThemeRaw(themeId); + if (!rawTheme) { + console.warn(`[ThemesManager] Theme '${themeId}' not found in IndexedDB`); + return; + } + + // Convert compressed Uint8Array to base64 for Yjs storage + const base64Compressed = project._yjsBridge._uint8ArrayToBase64(rawTheme.compressedFiles); + + // Store compressed theme in Yjs (single string, not Y.Map) + themeFilesMap.set(themeId, base64Compressed); + getLogger().log(`[ThemesManager] Copied user theme '${themeId}' to Yjs for collaboration`); + } catch (error) { + console.error(`[ThemesManager] Error copying theme '${themeId}' to Yjs:`, error); + } + } + + /** + * Remove user theme from Yjs themeFiles (but keep in IndexedDB) + * Called when user selects a different theme. + * The theme remains in IndexedDB for the user to use in other projects. + * @param {string} themeId - Theme ID to remove from Yjs + * @private + */ + async _removeUserThemeFromYjs(themeId) { + const project = this.app.project; + if (!project?._yjsBridge) return; + + const documentManager = project._yjsBridge.getDocumentManager(); + if (!documentManager) return; + + const themeFilesMap = documentManager.getThemeFiles(); + if (themeFilesMap.has(themeId)) { + themeFilesMap.delete(themeId); + getLogger().log(`[ThemesManager] Removed user theme '${themeId}' from Yjs (kept in IndexedDB)`); + } + } + /** * Select a theme * @param {string} id - Theme ID @@ -122,7 +190,21 @@ export default class ThemesManager { await this.selected.select(true); } } - // Save to Yjs instead of userPreferences + + // If previous theme was a user theme and we're selecting a different theme, + // remove the previous theme from Yjs (but keep in IndexedDB) + if (save && prevThemeSelected && prevThemeSelected.id !== id) { + if (prevThemeSelected.isUserTheme || prevThemeSelected.type === 'user') { + await this._removeUserThemeFromYjs(prevThemeSelected.id); + } + } + + // If saving and this is a user theme, ensure it's in Yjs for collaboration + if (save && (themeSelected.isUserTheme || themeSelected.type === 'user')) { + await this._ensureUserThemeInYjs(themeSelected.id, themeSelected); + } + + // Save to Yjs metadata if (save) { this.saveThemeToYjs(id); } @@ -133,12 +215,10 @@ export default class ThemesManager { * */ getThemeIcons() { - if (this.selected.icons) { + if (this.selected?.icons) { return this.selected.icons; - } else { - //return this.iconsDefault; - return {}; } + return {}; } /** diff --git a/public/app/workarea/themes/themesManager.test.js b/public/app/workarea/themes/themesManager.test.js index 00e866c2e..102d665f2 100644 --- a/public/app/workarea/themes/themesManager.test.js +++ b/public/app/workarea/themes/themesManager.test.js @@ -466,6 +466,126 @@ describe('ThemesManager', () => { }); }); + describe('_ensureUserThemeInYjs', () => { + let mockThemeFilesMap; + let mockResourceCache; + + beforeEach(() => { + mockThemeFilesMap = new Map(); + mockThemeFilesMap.has = vi.fn((key) => mockThemeFilesMap._data?.has(key)); + mockThemeFilesMap.set = vi.fn((key, value) => { + if (!mockThemeFilesMap._data) mockThemeFilesMap._data = new Map(); + mockThemeFilesMap._data.set(key, value); + }); + mockThemeFilesMap._data = new Map(); + + mockDocumentManager.getThemeFiles = vi.fn(() => mockThemeFilesMap); + + mockResourceCache = { + getUserThemeRaw: vi.fn(), + }; + + mockBridge.resourceCache = mockResourceCache; + mockBridge._uint8ArrayToBase64 = vi.fn((arr) => 'base64data'); + }); + + it('should not throw when bridge is not available', async () => { + themesManager.app.project._yjsBridge = null; + + await expect(themesManager._ensureUserThemeInYjs('user-theme', {})).resolves.not.toThrow(); + }); + + it('should not throw when documentManager is not available', async () => { + mockBridge.getDocumentManager.mockReturnValue(null); + + await expect(themesManager._ensureUserThemeInYjs('user-theme', {})).resolves.not.toThrow(); + }); + + it('should not copy if theme already in Yjs', async () => { + mockThemeFilesMap._data.set('user-theme', 'existing-data'); + mockThemeFilesMap.has.mockReturnValue(true); + + await themesManager._ensureUserThemeInYjs('user-theme', {}); + + expect(mockResourceCache.getUserThemeRaw).not.toHaveBeenCalled(); + }); + + it('should not copy if resourceCache is not available', async () => { + mockBridge.resourceCache = null; + + await themesManager._ensureUserThemeInYjs('user-theme', {}); + + expect(mockThemeFilesMap.set).not.toHaveBeenCalled(); + }); + + it('should not copy if theme not found in IndexedDB', async () => { + mockResourceCache.getUserThemeRaw.mockResolvedValue(null); + + await themesManager._ensureUserThemeInYjs('user-theme', {}); + + expect(mockThemeFilesMap.set).not.toHaveBeenCalled(); + }); + + it('should copy theme to Yjs when not already there', async () => { + const mockCompressed = new Uint8Array([1, 2, 3]); + mockResourceCache.getUserThemeRaw.mockResolvedValue({ + compressedFiles: mockCompressed, + }); + + await themesManager._ensureUserThemeInYjs('user-theme', {}); + + expect(mockBridge._uint8ArrayToBase64).toHaveBeenCalledWith(mockCompressed); + expect(mockThemeFilesMap.set).toHaveBeenCalledWith('user-theme', 'base64data'); + }); + + it('should handle errors gracefully', async () => { + mockResourceCache.getUserThemeRaw.mockRejectedValue(new Error('DB error')); + + await expect(themesManager._ensureUserThemeInYjs('user-theme', {})).resolves.not.toThrow(); + }); + }); + + describe('_removeUserThemeFromYjs', () => { + let mockThemeFilesMap; + + beforeEach(() => { + mockThemeFilesMap = new Map(); + mockThemeFilesMap.has = vi.fn((key) => mockThemeFilesMap._data?.has(key)); + mockThemeFilesMap.delete = vi.fn((key) => mockThemeFilesMap._data?.delete(key)); + mockThemeFilesMap._data = new Map(); + + mockDocumentManager.getThemeFiles = vi.fn(() => mockThemeFilesMap); + }); + + it('should not throw when bridge is not available', async () => { + themesManager.app.project._yjsBridge = null; + + await expect(themesManager._removeUserThemeFromYjs('user-theme')).resolves.not.toThrow(); + }); + + it('should not throw when documentManager is not available', async () => { + mockBridge.getDocumentManager.mockReturnValue(null); + + await expect(themesManager._removeUserThemeFromYjs('user-theme')).resolves.not.toThrow(); + }); + + it('should remove theme from Yjs themeFiles', async () => { + mockThemeFilesMap._data.set('user-theme', 'theme-data'); + mockThemeFilesMap.has.mockReturnValue(true); + + await themesManager._removeUserThemeFromYjs('user-theme'); + + expect(mockThemeFilesMap.delete).toHaveBeenCalledWith('user-theme'); + }); + + it('should not throw if theme not in Yjs', async () => { + mockThemeFilesMap.has.mockReturnValue(false); + + await expect(themesManager._removeUserThemeFromYjs('non-existent')).resolves.not.toThrow(); + expect(mockThemeFilesMap.delete).not.toHaveBeenCalled(); + }); + }); + describe('integration', () => { it('should initialize Yjs and handle theme sync', () => { mockMetadata._data.set('theme', 'test-theme'); diff --git a/public/app/workarea/user/preferences/userPreferences.js b/public/app/workarea/user/preferences/userPreferences.js index a41234662..d2f1881d8 100644 --- a/public/app/workarea/user/preferences/userPreferences.js +++ b/public/app/workarea/user/preferences/userPreferences.js @@ -85,21 +85,22 @@ export default class UserPreferences { for (let [key, value] of Object.entries(preferences)) { params[key] = value; } - // Save in database - eXeLearning.app.api.putSaveUserPreferences(params).then((response) => { - // Update interface advanced class - if (preferences.advancedMode) - this.manager.reloadMode(preferences.advancedMode); - // Update interface versionControl class - if (preferences.versionControl) - this.manager.reloadVersionControl(preferences.versionControl); - // Update interface lang - if (preferences.locale) this.manager.reloadLang(preferences.locale); - // Reloading of the page so that it takes a possible change of language in the user preferences - if (params['locale'] !== undefined) { - window.onbeforeunload = null; - window.location.reload(); - } - }); + // Save in database/localStorage + await eXeLearning.app.api.putSaveUserPreferences(params); + + // Update interface advanced class + if (preferences.advancedMode) + this.manager.reloadMode(preferences.advancedMode); + // Update interface versionControl class + if (preferences.versionControl) + this.manager.reloadVersionControl(preferences.versionControl); + // Update interface lang + if (preferences.locale) await this.manager.reloadLang(preferences.locale); + + // Reloading of the page so that it takes a possible change of language in the user preferences + if (params['locale'] !== undefined) { + window.onbeforeunload = null; + window.location.reload(); + } } } diff --git a/public/app/workarea/user/preferences/userPreferences.test.js b/public/app/workarea/user/preferences/userPreferences.test.js index 7b663a08a..d0f5908ac 100644 --- a/public/app/workarea/user/preferences/userPreferences.test.js +++ b/public/app/workarea/user/preferences/userPreferences.test.js @@ -38,7 +38,7 @@ describe('UserPreferences', () => { mockManager = { reloadMode: vi.fn(), reloadVersionControl: vi.fn(), - reloadLang: vi.fn(), + reloadLang: vi.fn().mockResolvedValue(), app: globalThis.eXeLearning.app }; @@ -113,11 +113,8 @@ describe('UserPreferences', () => { advancedMode: 'true', locale: 'fr' }); - - // Wait for promise resolution in then() - await new Promise(resolve => setTimeout(resolve, 0)); - expect(mockManager.reloadMode).toHaveBeenCalledWith('true'); + expect(mockManager.reloadLang).toHaveBeenCalledWith('fr'); expect(window.location.reload).toHaveBeenCalled(); window.location = originalLocation; diff --git a/public/app/workarea/user/userManager.js b/public/app/workarea/user/userManager.js index 1195e33d6..c0958a870 100644 --- a/public/app/workarea/user/userManager.js +++ b/public/app/workarea/user/userManager.js @@ -85,7 +85,8 @@ export default class UserManager { * * @param {*} lang */ - reloadLang(lang) { - eXeLearning.app.locale.setLocaleLang(lang); + async reloadLang(lang) { + await eXeLearning.app.locale.setLocaleLang(lang); + await eXeLearning.app.locale.loadTranslationsStrings(); } } diff --git a/public/app/workarea/user/userManager.test.js b/public/app/workarea/user/userManager.test.js index 072daf1d7..2ad470e2e 100644 --- a/public/app/workarea/user/userManager.test.js +++ b/public/app/workarea/user/userManager.test.js @@ -25,7 +25,8 @@ describe('UserManager', () => { postDeleteOdeFilesByDate: vi.fn().mockResolvedValue({}), }, locale: { - setLocaleLang: vi.fn(), + setLocaleLang: vi.fn().mockResolvedValue(), + loadTranslationsStrings: vi.fn().mockResolvedValue(), } } }; @@ -99,9 +100,10 @@ describe('UserManager', () => { }); describe('reloadLang', () => { - it('should call setLocaleLang', () => { - userManager.reloadLang('es'); + it('should call setLocaleLang and loadTranslationsStrings', async () => { + await userManager.reloadLang('es'); expect(globalThis.eXeLearning.app.locale.setLocaleLang).toHaveBeenCalledWith('es'); + expect(globalThis.eXeLearning.app.locale.loadTranslationsStrings).toHaveBeenCalled(); }); }); }); diff --git a/public/app/workarea/utils/LinkValidationManager.js b/public/app/workarea/utils/LinkValidationManager.js index 99226c418..0a962e6ea 100644 --- a/public/app/workarea/utils/LinkValidationManager.js +++ b/public/app/workarea/utils/LinkValidationManager.js @@ -149,6 +149,34 @@ export default class LinkValidationManager { return new Promise((resolve, reject) => { const streamUrl = eXeLearning.app.api.getLinkValidationStreamUrl(); + // If no stream URL available (static mode), skip validation + // and mark all links as "unvalidated" (not broken, just not checked) + if (!streamUrl) { + console.log('[LinkValidationManager] No validation stream URL available - skipping server validation'); + this.isValidating = false; + + // Mark links as "valid" (unvalidated) since we can't check them + // In static mode, we just show the links without validation status + for (const link of this.links.values()) { + link.status = 'valid'; // Show as valid (green checkmark) + link.error = null; + + if (this.onLinkUpdate) { + this.onLinkUpdate(link.id, link.status, link.error, link); + } + + if (this.onProgress) { + this.onProgress(this.getStats()); + } + } + + if (this.onComplete) { + this.onComplete(this.getStats(), false); + } + resolve(); + return; + } + this.streamHandle = SSEClient.createStream( streamUrl, { links }, diff --git a/public/app/yjs/AssetManager.js b/public/app/yjs/AssetManager.js index baac3d773..bcd7be027 100644 --- a/public/app/yjs/AssetManager.js +++ b/public/app/yjs/AssetManager.js @@ -239,14 +239,46 @@ class AssetManager { /** * Calculate SHA-256 hash of blob + * Falls back to a simple hash if crypto.subtle is not available + * (crypto.subtle requires secure context - HTTPS or localhost) * @param {Blob} blob * @returns {Promise} Hex string hash */ async calculateHash(blob) { const arrayBuffer = await blob.arrayBuffer(); - const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + + // crypto.subtle is only available in secure contexts (HTTPS or localhost) + // In non-secure contexts (HTTP on IP address), use a fallback hash + if (crypto.subtle?.digest) { + const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + } + + // Fallback: Simple FNV-1a hash (32-bit) expanded to 64 chars + // Not cryptographically secure, but sufficient for asset deduplication + const data = new Uint8Array(arrayBuffer); + let hash = 2166136261; // FNV offset basis + for (let i = 0; i < data.length; i++) { + hash ^= data[i]; + hash = (hash * 16777619) >>> 0; // FNV prime, keep as 32-bit unsigned + } + // Expand to 64 hex chars by combining hash with size and sampling + const sizeHash = (data.length * 2654435761) >>> 0; + const sample1 = data.length > 0 ? data[0] : 0; + const sample2 = data.length > 100 ? data[100] : 0; + const sample3 = data.length > 1000 ? data[1000] : 0; + const combined = [ + hash.toString(16).padStart(8, '0'), + sizeHash.toString(16).padStart(8, '0'), + (hash ^ sizeHash).toString(16).padStart(8, '0'), + ((hash + sample1 + sample2 + sample3) >>> 0).toString(16).padStart(8, '0'), + data.length.toString(16).padStart(8, '0'), + ((hash * 31 + sizeHash) >>> 0).toString(16).padStart(8, '0'), + ((sizeHash ^ sample1 ^ sample2 ^ sample3) >>> 0).toString(16).padStart(8, '0'), + ((hash ^ data.length) >>> 0).toString(16).padStart(8, '0'), + ].join(''); + return combined; } /** diff --git a/public/app/yjs/ElpxImporter.js b/public/app/yjs/ElpxImporter.js index 6953b7f40..99bd68baa 100644 --- a/public/app/yjs/ElpxImporter.js +++ b/public/app/yjs/ElpxImporter.js @@ -1543,6 +1543,14 @@ class ElpxImporter { ); str = str.replace(resourcesPattern, `$1asset://${assetId}/${fileName}`); + // 2b. Replace /resources/filename (with leading slash) when preceded by attribute quote + // This handles absolute-style resource paths like src="/resources/image.png" + const absoluteResourcesPattern = new RegExp( + `(["']|"|'|')/resources/${escapedFileName}`, + 'g' + ); + str = str.replace(absoluteResourcesPattern, `$1asset://${assetId}/${fileName}`); + // 3. Replace bare resources/filename paths (for raw path properties like image gallery) // These are object values (not HTML attributes), so they don't have preceding quotes // The string itself IS the path, e.g., "resources/image.jpg" diff --git a/public/app/yjs/ResourceCache.js b/public/app/yjs/ResourceCache.js index f1157f324..ca99975d4 100644 --- a/public/app/yjs/ResourceCache.js +++ b/public/app/yjs/ResourceCache.js @@ -23,13 +23,36 @@ class ResourceCache { static DB_NAME = 'exelearning-resources-v1'; - static DB_VERSION = 1; + static DB_VERSION = 3; // Bumped for per-user themes static STORE_NAME = 'resources'; + static USER_THEMES_STORE = 'user-themes'; constructor() { this.db = null; } + /** + * Get current user ID for per-user storage + * Falls back to 'anonymous' if user not available + * @returns {string} + * @private + */ + _getCurrentUserId() { + // Use the same pattern as iDevices favorites (menuIdevicesBottom.js) + return window.eXeLearning?.app?.user?.name || 'anonymous'; + } + + /** + * Build storage key for user theme (includes user ID) + * @param {string} themeName - Theme name + * @returns {string} Key in format "userId:themeName" + * @private + */ + _buildUserThemeKey(themeName) { + const userId = this._getCurrentUserId(); + return `${userId}:${themeName}`; + } + /** * Build cache key from type, name, and version * @param {string} type - Resource type ('theme', 'idevice', 'libs', etc.) @@ -65,6 +88,7 @@ class ResourceCache { request.onupgradeneeded = (event) => { const db = event.target.result; + const oldVersion = event.oldVersion; if (!db.objectStoreNames.contains(ResourceCache.STORE_NAME)) { const store = db.createObjectStore(ResourceCache.STORE_NAME, { @@ -78,6 +102,27 @@ class ResourceCache { Logger.log('[ResourceCache] Created resources object store'); } + + // Version 3: Recreate user-themes store with per-user support + // Delete old store if upgrading from version 2 + if (oldVersion === 2 && db.objectStoreNames.contains(ResourceCache.USER_THEMES_STORE)) { + db.deleteObjectStore(ResourceCache.USER_THEMES_STORE); + Logger.log('[ResourceCache] Deleted old user-themes store for migration'); + } + + // Create user-themes store with per-user key (userId:themeName) + if (!db.objectStoreNames.contains(ResourceCache.USER_THEMES_STORE)) { + const userThemesStore = db.createObjectStore(ResourceCache.USER_THEMES_STORE, { + keyPath: 'id', // Composite key: "userId:themeName" + }); + + // Index by userId for listing user's themes + userThemesStore.createIndex('userId', 'userId', { unique: false }); + // Index by importedAt for sorting + userThemesStore.createIndex('importedAt', 'importedAt', { unique: false }); + + Logger.log('[ResourceCache] Created user-themes object store (per-user)'); + } }; }); } @@ -330,6 +375,316 @@ class ResourceCache { }); } + // ======================================== + // User Themes Methods (persistent, version-independent) + // ======================================== + + /** + * Store a user theme in IndexedDB (per-user storage) + * @param {string} name - Theme name + * @param {Uint8Array} compressedFiles - ZIP compressed theme files + * @param {Object} config - Theme configuration from config.xml + * @param {string} config.displayName - Display name for UI + * @param {string} config.version - Theme version + * @param {string} config.author - Theme author + * @param {string} config.description - Theme description + * @param {string[]} config.cssFiles - List of CSS files + * @returns {Promise} + */ + async setUserTheme(name, compressedFiles, config) { + if (!this.db) throw new Error('Database not initialized'); + + const userId = this._getCurrentUserId(); + const id = this._buildUserThemeKey(name); + + const entry = { + id, // Composite key: "userId:themeName" + userId, // For index-based queries + name, // Theme name (for display) + files: compressedFiles, // ZIP compressed Uint8Array + config, + importedAt: Date.now(), + }; + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([ResourceCache.USER_THEMES_STORE], 'readwrite'); + const store = transaction.objectStore(ResourceCache.USER_THEMES_STORE); + const request = store.put(entry); + + request.onerror = () => { + console.error('[ResourceCache] setUserTheme failed:', request.error); + reject(request.error); + }; + + request.onsuccess = () => { + Logger.log(`[ResourceCache] Saved user theme: ${name} (user: ${userId})`); + resolve(); + }; + }); + } + + /** + * Get a user theme from IndexedDB (per-user storage) + * @param {string} name - Theme name + * @returns {Promise<{files: Map, config: Object}|null>} Theme data or null if not found + */ + async getUserTheme(name) { + if (!this.db) throw new Error('Database not initialized'); + + const id = this._buildUserThemeKey(name); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([ResourceCache.USER_THEMES_STORE], 'readonly'); + const store = transaction.objectStore(ResourceCache.USER_THEMES_STORE); + const request = store.get(id); + + request.onerror = () => { + console.error('[ResourceCache] getUserTheme failed:', request.error); + reject(request.error); + }; + + request.onsuccess = () => { + const result = request.result; + if (!result) { + resolve(null); + return; + } + + // Decompress ZIP to Map + try { + const files = this._decompressThemeFiles(result.files); + Logger.log(`[ResourceCache] Retrieved user theme: ${name} (${files.size} files)`); + resolve({ files, config: result.config }); + } catch (error) { + console.error('[ResourceCache] Failed to decompress theme:', error); + reject(error); + } + }; + }); + } + + /** + * Get raw compressed data for a user theme (for Yjs sync, per-user storage) + * @param {string} name - Theme name + * @returns {Promise<{compressedFiles: Uint8Array, config: Object}|null>} Raw compressed data or null + */ + async getUserThemeRaw(name) { + if (!this.db) throw new Error('Database not initialized'); + + const id = this._buildUserThemeKey(name); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([ResourceCache.USER_THEMES_STORE], 'readonly'); + const store = transaction.objectStore(ResourceCache.USER_THEMES_STORE); + const request = store.get(id); + + request.onerror = () => { + console.error('[ResourceCache] getUserThemeRaw failed:', request.error); + reject(request.error); + }; + + request.onsuccess = () => { + const result = request.result; + if (!result) { + resolve(null); + return; + } + + resolve({ + compressedFiles: result.files, + config: result.config, + }); + }; + }); + } + + /** + * Check if a user theme exists in IndexedDB (per-user storage) + * @param {string} name - Theme name + * @returns {Promise} + */ + async hasUserTheme(name) { + if (!this.db) throw new Error('Database not initialized'); + + const id = this._buildUserThemeKey(name); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([ResourceCache.USER_THEMES_STORE], 'readonly'); + const store = transaction.objectStore(ResourceCache.USER_THEMES_STORE); + const request = store.count(IDBKeyRange.only(id)); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result > 0); + }); + } + + /** + * Update a user theme's config in IndexedDB (per-user storage) + * Only updates the config fields, keeps the theme files unchanged + * @param {string} name - Theme name + * @param {Object} configUpdates - Fields to update in config + * @returns {Promise} + */ + async updateUserThemeConfig(name, configUpdates) { + if (!this.db) throw new Error('Database not initialized'); + + const id = this._buildUserThemeKey(name); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([ResourceCache.USER_THEMES_STORE], 'readwrite'); + const store = transaction.objectStore(ResourceCache.USER_THEMES_STORE); + const getRequest = store.get(id); + + getRequest.onerror = () => { + console.error('[ResourceCache] updateUserThemeConfig get failed:', getRequest.error); + reject(getRequest.error); + }; + + getRequest.onsuccess = () => { + const existing = getRequest.result; + if (!existing) { + reject(new Error(`Theme '${name}' not found`)); + return; + } + + // Merge config updates + const updatedEntry = { + ...existing, + config: { + ...existing.config, + ...configUpdates, + }, + modifiedAt: Date.now(), + }; + + const putRequest = store.put(updatedEntry); + + putRequest.onerror = () => { + console.error('[ResourceCache] updateUserThemeConfig put failed:', putRequest.error); + reject(putRequest.error); + }; + + putRequest.onsuccess = () => { + Logger.log(`[ResourceCache] Updated user theme config: ${name}`); + resolve(); + }; + }; + }); + } + + /** + * Delete a user theme from IndexedDB (per-user storage) + * @param {string} name - Theme name + * @returns {Promise} + */ + async deleteUserTheme(name) { + if (!this.db) throw new Error('Database not initialized'); + + const id = this._buildUserThemeKey(name); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([ResourceCache.USER_THEMES_STORE], 'readwrite'); + const store = transaction.objectStore(ResourceCache.USER_THEMES_STORE); + const request = store.delete(id); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + Logger.log(`[ResourceCache] Deleted user theme: ${name}`); + resolve(); + }; + }); + } + + /** + * List all user themes in IndexedDB for the current user + * @returns {Promise>} + */ + async listUserThemes() { + if (!this.db) throw new Error('Database not initialized'); + + const userId = this._getCurrentUserId(); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([ResourceCache.USER_THEMES_STORE], 'readonly'); + const store = transaction.objectStore(ResourceCache.USER_THEMES_STORE); + const index = store.index('userId'); + const request = index.openCursor(IDBKeyRange.only(userId)); + + const themes = []; + + request.onerror = () => reject(request.error); + + request.onsuccess = (event) => { + const cursor = event.target.result; + if (cursor) { + // Only return metadata, not the actual files + themes.push({ + name: cursor.value.name, + config: cursor.value.config, + importedAt: cursor.value.importedAt, + }); + cursor.continue(); + } else { + // Sort by importedAt descending (most recent first) + themes.sort((a, b) => b.importedAt - a.importedAt); + resolve(themes); + } + }; + }); + } + + /** + * Decompress ZIP file to Map + * Uses fflate library (must be loaded globally) + * @param {Uint8Array} compressed - ZIP compressed data + * @returns {Map} + * @private + */ + _decompressThemeFiles(compressed) { + if (!window.fflate) { + throw new Error('fflate library not loaded'); + } + + const decompressed = window.fflate.unzipSync(compressed); + const files = new Map(); + + for (const [path, data] of Object.entries(decompressed)) { + // Convert Uint8Array to Blob with appropriate MIME type + const mimeType = this._getMimeType(path); + const blob = new Blob([data], { type: mimeType }); + files.set(path, blob); + } + + return files; + } + + /** + * Get MIME type for file path + * @param {string} path - File path + * @returns {string} MIME type + * @private + */ + _getMimeType(path) { + const ext = path.split('.').pop()?.toLowerCase(); + const mimeTypes = { + css: 'text/css', + js: 'application/javascript', + html: 'text/html', + xml: 'application/xml', + json: 'application/json', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + svg: 'image/svg+xml', + woff: 'font/woff', + woff2: 'font/woff2', + ttf: 'font/ttf', + eot: 'application/vnd.ms-fontobject', + }; + return mimeTypes[ext] || 'application/octet-stream'; + } + /** * Close database connection */ diff --git a/public/app/yjs/ResourceCache.test.js b/public/app/yjs/ResourceCache.test.js index bf80be3dd..6336dd37a 100644 --- a/public/app/yjs/ResourceCache.test.js +++ b/public/app/yjs/ResourceCache.test.js @@ -213,7 +213,7 @@ describe('ResourceCache', () => { it('opens IndexedDB database', async () => { await cache.init(); - expect(global.indexedDB.open).toHaveBeenCalledWith('exelearning-resources-v1', 1); + expect(global.indexedDB.open).toHaveBeenCalledWith('exelearning-resources-v1', 3); expect(cache.db).toBe(mockDB); }); @@ -500,4 +500,542 @@ describe('ResourceCache', () => { expect(result).toBeNull(); }); }); + + describe('per-user theme storage', () => { + describe('_getCurrentUserId', () => { + it('returns user name from eXeLearning app', () => { + global.window = { + eXeLearning: { + app: { + user: { name: 'testuser' }, + }, + }, + }; + + expect(cache._getCurrentUserId()).toBe('testuser'); + }); + + it('returns anonymous if user not available', () => { + global.window = {}; + + expect(cache._getCurrentUserId()).toBe('anonymous'); + }); + + it('returns anonymous if eXeLearning not available', () => { + global.window = { eXeLearning: null }; + + expect(cache._getCurrentUserId()).toBe('anonymous'); + }); + }); + + describe('_buildUserThemeKey', () => { + beforeEach(() => { + global.window = { + eXeLearning: { + app: { + user: { name: 'user1' }, + }, + }, + }; + }); + + it('builds key with userId prefix', () => { + expect(cache._buildUserThemeKey('my-theme')).toBe('user1:my-theme'); + }); + + it('uses anonymous for missing user', () => { + global.window = {}; + expect(cache._buildUserThemeKey('my-theme')).toBe('anonymous:my-theme'); + }); + }); + + describe('user theme isolation', () => { + let userThemesStore; + + beforeEach(async () => { + // Create separate store for user themes + userThemesStore = new Map(); + + // Mock user themes store operations + mockStore.put = mock((entry) => { + const request = { onsuccess: null, onerror: null }; + setTimeout(() => { + // Use 'id' as key (composite key with userId) + const key = entry.id || entry.key; + if (entry.userId !== undefined) { + userThemesStore.set(key, entry); + } else { + storedResources.set(key, entry); + } + if (request.onsuccess) request.onsuccess(); + }, 0); + return request; + }); + + mockStore.get = mock((key) => { + const request = { + result: userThemesStore.get(key) || storedResources.get(key) || null, + onsuccess: null, + onerror: null, + }; + setTimeout(() => { + if (request.onsuccess) request.onsuccess(); + }, 0); + return request; + }); + + mockStore.delete = mock((key) => { + const request = { onsuccess: null, onerror: null }; + setTimeout(() => { + userThemesStore.delete(key); + storedResources.delete(key); + if (request.onsuccess) request.onsuccess(); + }, 0); + return request; + }); + + mockStore.count = mock((range) => { + const key = range ? range._value : null; + const request = { + result: key ? (userThemesStore.has(key) ? 1 : 0) : userThemesStore.size, + onsuccess: null, + onerror: null, + }; + setTimeout(() => { + if (request.onsuccess) request.onsuccess(); + }, 0); + return request; + }); + + // Mock index for userId filtering + mockStore.index = mock((indexName) => ({ + openCursor: mock((range) => { + const userId = range ? range._value : null; + const entries = []; + for (const [key, value] of userThemesStore.entries()) { + if (indexName === 'userId' && value.userId === userId) { + entries.push({ primaryKey: key, value }); + } + } + let idx = 0; + const request = { onsuccess: null, onerror: null }; + setTimeout(() => { + const emitNext = () => { + if (idx < entries.length) { + const entry = entries[idx++]; + if (request.onsuccess) { + request.onsuccess({ + target: { + result: { + primaryKey: entry.primaryKey, + value: entry.value, + continue: () => setTimeout(emitNext, 0), + }, + }, + }); + } + } else { + if (request.onsuccess) { + request.onsuccess({ target: { result: null } }); + } + } + }; + emitNext(); + }, 0); + return request; + }), + })); + + await cache.init(); + }); + + afterEach(() => { + userThemesStore = null; + delete global.window; + }); + + it('stores theme with userId prefix in key', async () => { + global.window = { + eXeLearning: { app: { user: { name: 'alice' } } }, + fflate: { unzipSync: () => ({}) }, + }; + + const compressedFiles = new Uint8Array([1, 2, 3]); + const config = { displayName: 'Test Theme' }; + + await cache.setUserTheme('my-theme', compressedFiles, config); + + expect(userThemesStore.has('alice:my-theme')).toBe(true); + const stored = userThemesStore.get('alice:my-theme'); + expect(stored.userId).toBe('alice'); + expect(stored.name).toBe('my-theme'); + }); + + it('isolates themes between users', async () => { + const compressedFiles = new Uint8Array([1, 2, 3]); + const config = { displayName: 'Theme' }; + + // User alice stores a theme + global.window = { + eXeLearning: { app: { user: { name: 'alice' } } }, + fflate: { unzipSync: () => ({}) }, + }; + await cache.setUserTheme('shared-name', compressedFiles, config); + + // User bob stores a theme with the same name + global.window = { + eXeLearning: { app: { user: { name: 'bob' } } }, + fflate: { unzipSync: () => ({}) }, + }; + await cache.setUserTheme('shared-name', compressedFiles, { displayName: 'Bob Theme' }); + + // Both themes exist with different keys + expect(userThemesStore.has('alice:shared-name')).toBe(true); + expect(userThemesStore.has('bob:shared-name')).toBe(true); + + // Themes have correct data + expect(userThemesStore.get('alice:shared-name').config.displayName).toBe('Theme'); + expect(userThemesStore.get('bob:shared-name').config.displayName).toBe('Bob Theme'); + }); + + it('hasUserTheme only checks current user themes', async () => { + const compressedFiles = new Uint8Array([1, 2, 3]); + const config = { displayName: 'Theme' }; + + // Alice stores a theme + global.window = { + eXeLearning: { app: { user: { name: 'alice' } } }, + fflate: { unzipSync: () => ({}) }, + }; + await cache.setUserTheme('alice-theme', compressedFiles, config); + + // Check as Alice - should find it + expect(await cache.hasUserTheme('alice-theme')).toBe(true); + + // Check as Bob - should NOT find Alice's theme + global.window = { + eXeLearning: { app: { user: { name: 'bob' } } }, + fflate: { unzipSync: () => ({}) }, + }; + expect(await cache.hasUserTheme('alice-theme')).toBe(false); + }); + + it('listUserThemes only returns current user themes', async () => { + const compressedFiles = new Uint8Array([1, 2, 3]); + + // Alice stores two themes + global.window = { + eXeLearning: { app: { user: { name: 'alice' } } }, + fflate: { unzipSync: () => ({}) }, + }; + await cache.setUserTheme('alice-theme-1', compressedFiles, { displayName: 'Alice 1' }); + await cache.setUserTheme('alice-theme-2', compressedFiles, { displayName: 'Alice 2' }); + + // Bob stores one theme + global.window = { + eXeLearning: { app: { user: { name: 'bob' } } }, + fflate: { unzipSync: () => ({}) }, + }; + await cache.setUserTheme('bob-theme', compressedFiles, { displayName: 'Bob Theme' }); + + // List as Alice - should only see Alice's themes + global.window = { + eXeLearning: { app: { user: { name: 'alice' } } }, + fflate: { unzipSync: () => ({}) }, + }; + const aliceThemes = await cache.listUserThemes(); + expect(aliceThemes.length).toBe(2); + expect(aliceThemes.map((t) => t.name)).toContain('alice-theme-1'); + expect(aliceThemes.map((t) => t.name)).toContain('alice-theme-2'); + expect(aliceThemes.map((t) => t.name)).not.toContain('bob-theme'); + + // List as Bob - should only see Bob's theme + global.window = { + eXeLearning: { app: { user: { name: 'bob' } } }, + fflate: { unzipSync: () => ({}) }, + }; + const bobThemes = await cache.listUserThemes(); + expect(bobThemes.length).toBe(1); + expect(bobThemes[0].name).toBe('bob-theme'); + }); + + it('deleteUserTheme only deletes current user theme', async () => { + const compressedFiles = new Uint8Array([1, 2, 3]); + + // Alice stores a theme + global.window = { + eXeLearning: { app: { user: { name: 'alice' } } }, + fflate: { unzipSync: () => ({}) }, + }; + await cache.setUserTheme('my-theme', compressedFiles, { displayName: 'Alice' }); + + // Bob stores same-named theme + global.window = { + eXeLearning: { app: { user: { name: 'bob' } } }, + fflate: { unzipSync: () => ({}) }, + }; + await cache.setUserTheme('my-theme', compressedFiles, { displayName: 'Bob' }); + + // Bob deletes "my-theme" + await cache.deleteUserTheme('my-theme'); + + // Bob's theme is deleted + expect(userThemesStore.has('bob:my-theme')).toBe(false); + + // Alice's theme is NOT affected + expect(userThemesStore.has('alice:my-theme')).toBe(true); + }); + + it('updateUserThemeConfig updates config while keeping files', async () => { + const compressedFiles = new Uint8Array([1, 2, 3, 4, 5]); + const originalConfig = { + displayName: 'Original Name', + version: '1.0', + author: 'Original Author', + }; + + global.window = { + eXeLearning: { app: { user: { name: 'testuser' } } }, + fflate: { unzipSync: () => ({}) }, + }; + + // Store theme + await cache.setUserTheme('test-theme', compressedFiles, originalConfig); + + // Update config + await cache.updateUserThemeConfig('test-theme', { + displayName: 'Updated Name', + author: 'New Author', + }); + + // Verify the update + const stored = userThemesStore.get('testuser:test-theme'); + expect(stored.config.displayName).toBe('Updated Name'); + expect(stored.config.author).toBe('New Author'); + expect(stored.config.version).toBe('1.0'); // unchanged + expect(stored.files).toEqual(compressedFiles); // files unchanged + expect(stored.modifiedAt).toBeDefined(); + }); + + it('updateUserThemeConfig throws error if theme not found', async () => { + global.window = { + eXeLearning: { app: { user: { name: 'testuser' } } }, + fflate: { unzipSync: () => ({}) }, + }; + + await expect( + cache.updateUserThemeConfig('non-existent', { displayName: 'Test' }) + ).rejects.toThrow("Theme 'non-existent' not found"); + }); + + it('updateUserThemeConfig only updates current user theme', async () => { + const compressedFiles = new Uint8Array([1, 2, 3]); + + // Store themes directly in the mock store to simulate pre-existing themes + userThemesStore.set('alice:shared-name', { + id: 'alice:shared-name', + userId: 'alice', + name: 'shared-name', + files: compressedFiles, + config: { displayName: 'Alice Theme' }, + }); + userThemesStore.set('bob:shared-name', { + id: 'bob:shared-name', + userId: 'bob', + name: 'shared-name', + files: compressedFiles, + config: { displayName: 'Bob Theme' }, + }); + + // Set current user to Bob + global.window = { + eXeLearning: { app: { user: { name: 'bob' } } }, + fflate: { unzipSync: () => ({}) }, + }; + + // Bob updates his theme + await cache.updateUserThemeConfig('shared-name', { displayName: 'Bob Updated' }); + + // Bob's theme is updated + expect(userThemesStore.get('bob:shared-name').config.displayName).toBe('Bob Updated'); + + // Alice's theme is NOT affected + expect(userThemesStore.get('alice:shared-name').config.displayName).toBe('Alice Theme'); + }); + + it('getUserThemeRaw returns raw compressed data', async () => { + const compressedFiles = new Uint8Array([1, 2, 3, 4, 5]); + const config = { displayName: 'Raw Theme' }; + + global.window = { + eXeLearning: { app: { user: { name: 'testuser' } } }, + fflate: { unzipSync: () => ({}) }, + }; + + // Store theme + await cache.setUserTheme('raw-theme', compressedFiles, config); + + // Get raw data + const result = await cache.getUserThemeRaw('raw-theme'); + + expect(result).not.toBeNull(); + expect(result.compressedFiles).toEqual(compressedFiles); + expect(result.config).toEqual(config); + }); + + it('getUserThemeRaw returns null for non-existent theme', async () => { + global.window = { + eXeLearning: { app: { user: { name: 'testuser' } } }, + fflate: { unzipSync: () => ({}) }, + }; + + const result = await cache.getUserThemeRaw('non-existent'); + + expect(result).toBeNull(); + }); + + it('getUserThemeRaw isolates themes between users', async () => { + const compressedFiles = new Uint8Array([1, 2, 3]); + + // Alice stores a theme + global.window = { + eXeLearning: { app: { user: { name: 'alice' } } }, + fflate: { unzipSync: () => ({}) }, + }; + await cache.setUserTheme('private-theme', compressedFiles, { displayName: 'Alice Theme' }); + + // Bob tries to get Alice's theme + global.window = { + eXeLearning: { app: { user: { name: 'bob' } } }, + fflate: { unzipSync: () => ({}) }, + }; + const result = await cache.getUserThemeRaw('private-theme'); + + // Bob should not get Alice's theme + expect(result).toBeNull(); + }); + }); + }); + + describe('_getMimeType', () => { + beforeEach(async () => { + await cache.init(); + }); + + it('returns text/css for .css files', () => { + expect(cache._getMimeType('style.css')).toBe('text/css'); + }); + + it('returns application/javascript for .js files', () => { + expect(cache._getMimeType('script.js')).toBe('application/javascript'); + }); + + it('returns text/html for .html files', () => { + expect(cache._getMimeType('page.html')).toBe('text/html'); + }); + + it('returns application/xml for .xml files', () => { + expect(cache._getMimeType('config.xml')).toBe('application/xml'); + }); + + it('returns application/json for .json files', () => { + expect(cache._getMimeType('data.json')).toBe('application/json'); + }); + + it('returns image/png for .png files', () => { + expect(cache._getMimeType('image.png')).toBe('image/png'); + }); + + it('returns image/jpeg for .jpg and .jpeg files', () => { + expect(cache._getMimeType('photo.jpg')).toBe('image/jpeg'); + expect(cache._getMimeType('photo.jpeg')).toBe('image/jpeg'); + }); + + it('returns image/gif for .gif files', () => { + expect(cache._getMimeType('anim.gif')).toBe('image/gif'); + }); + + it('returns image/svg+xml for .svg files', () => { + expect(cache._getMimeType('icon.svg')).toBe('image/svg+xml'); + }); + + it('returns font/woff for .woff files', () => { + expect(cache._getMimeType('font.woff')).toBe('font/woff'); + }); + + it('returns font/woff2 for .woff2 files', () => { + expect(cache._getMimeType('font.woff2')).toBe('font/woff2'); + }); + + it('returns font/ttf for .ttf files', () => { + expect(cache._getMimeType('font.ttf')).toBe('font/ttf'); + }); + + it('returns application/vnd.ms-fontobject for .eot files', () => { + expect(cache._getMimeType('font.eot')).toBe('application/vnd.ms-fontobject'); + }); + + it('returns application/octet-stream for unknown extensions', () => { + expect(cache._getMimeType('file.xyz')).toBe('application/octet-stream'); + expect(cache._getMimeType('file.unknown')).toBe('application/octet-stream'); + }); + + it('handles files with no extension', () => { + expect(cache._getMimeType('noextension')).toBe('application/octet-stream'); + }); + + it('handles files with multiple dots', () => { + expect(cache._getMimeType('file.name.css')).toBe('text/css'); + expect(cache._getMimeType('my.script.js')).toBe('application/javascript'); + }); + }); + + describe('_decompressThemeFiles', () => { + beforeEach(async () => { + await cache.init(); + }); + + it('throws error when fflate is not loaded', () => { + global.window = {}; + + expect(() => cache._decompressThemeFiles(new Uint8Array([1]))).toThrow( + 'fflate library not loaded' + ); + }); + + it('decompresses ZIP and returns Map of files', () => { + global.window = { + fflate: { + unzipSync: mock(() => ({ + 'style.css': new Uint8Array([99, 115, 115]), + 'script.js': new Uint8Array([106, 115]), + })), + }, + }; + + const result = cache._decompressThemeFiles(new Uint8Array([1, 2, 3])); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(2); + expect(result.has('style.css')).toBe(true); + expect(result.has('script.js')).toBe(true); + }); + + it('converts Uint8Array to Blob with correct MIME type', () => { + global.window = { + fflate: { + unzipSync: mock(() => ({ + 'style.css': new Uint8Array([99, 115, 115]), + 'image.png': new Uint8Array([1, 2, 3]), + })), + }, + }; + + const result = cache._decompressThemeFiles(new Uint8Array([1, 2, 3])); + + expect(result.get('style.css')).toBeInstanceOf(Blob); + expect(result.get('style.css').type).toBe('text/css'); + expect(result.get('image.png').type).toBe('image/png'); + }); + }); }); diff --git a/public/app/yjs/ResourceFetcher.js b/public/app/yjs/ResourceFetcher.js index a03a28f01..352f74697 100644 --- a/public/app/yjs/ResourceFetcher.js +++ b/public/app/yjs/ResourceFetcher.js @@ -47,6 +47,73 @@ class ResourceFetcher { this.bundleManifest = null; // Whether bundles are available this.bundlesAvailable = false; + // Static mode detection (cached for performance) + this._isStaticMode = null; + // User theme files (from .elpx imports, stored in Yjs) + // Map> + this.userThemeFiles = new Map(); + } + + /** + * Check if running in static (offline) mode. + * Prefers capabilities check, falls back to direct mode detection. + * @returns {boolean} + */ + isStaticMode() { + if (this._isStaticMode === null) { + // Prefer capabilities check (new pattern) + const capabilities = window.eXeLearning?.app?.capabilities; + if (capabilities) { + this._isStaticMode = !capabilities.storage.remote; + } else { + // Fallback to direct detection + this._isStaticMode = window.__EXE_STATIC_MODE__ === true || + window.eXeLearning?.config?.isStaticMode === true; + } + } + return this._isStaticMode; + } + + /** + * Get the URL for a bundle file, handling static mode + * @param {string} bundleType - Type of bundle (theme, idevices, libs, etc.) + * @param {string} [name] - Name for named bundles (e.g., theme name) + * @returns {string} Bundle URL + */ + getBundleUrl(bundleType, name = null) { + if (this.isStaticMode()) { + // In static mode, bundles are in ./bundles/ folder + switch (bundleType) { + case 'theme': + return `${this.basePath}/bundles/themes/${name}.zip`; + case 'idevices': + return `${this.basePath}/bundles/idevices.zip`; + case 'libs': + return `${this.basePath}/bundles/libs.zip`; + case 'common': + return `${this.basePath}/bundles/common.zip`; + case 'content-css': + return `${this.basePath}/bundles/content-css.zip`; + default: + return `${this.basePath}/bundles/${bundleType}.zip`; + } + } + + // Server mode: use API endpoints + switch (bundleType) { + case 'theme': + return `${this.apiBase}/bundle/theme/${name}`; + case 'idevices': + return `${this.apiBase}/bundle/idevices`; + case 'libs': + return `${this.apiBase}/bundle/libs`; + case 'common': + return `${this.apiBase}/bundle/common`; + case 'content-css': + return `${this.apiBase}/bundle/content-css`; + default: + return `${this.apiBase}/bundle/${bundleType}`; + } } /** @@ -72,10 +139,139 @@ class ResourceFetcher { } /** - * Load bundle manifest from server + * Set user theme files imported from .elpx + * User themes are stored client-side in Yjs and need to be registered + * with ResourceFetcher for export functionality. + * @param {string} themeName - Theme name/directory + * @param {Object} files - Map of relativePath -> file content + */ + async setUserThemeFiles(themeName, files) { + this.userThemeFiles.set(themeName, files); + Logger.log(`[ResourceFetcher] Registered user theme '${themeName}' with ${Object.keys(files).length} files`); + + // Also update the in-memory cache + const cacheKey = `theme:${themeName}`; + const themeFiles = new Map(); + + // Convert Uint8Array to Blob for consistency with other themes + for (const [relativePath, uint8Array] of Object.entries(files)) { + const ext = relativePath.split('.').pop()?.toLowerCase() || ''; + const mimeTypes = { + css: 'text/css', + js: 'application/javascript', + json: 'application/json', + html: 'text/html', + xml: 'text/xml', + svg: 'image/svg+xml', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + woff: 'font/woff', + woff2: 'font/woff2', + ttf: 'font/ttf', + }; + const mimeType = mimeTypes[ext] || 'application/octet-stream'; + const blob = new Blob([uint8Array], { type: mimeType }); + themeFiles.set(relativePath, blob); + } + + this.cache.set(cacheKey, themeFiles); + } + + /** + * Check if a theme is a user theme (stored in Yjs) + * @param {string} themeName - Theme name + * @returns {boolean} + */ + hasUserTheme(themeName) { + return this.userThemeFiles.has(themeName); + } + + /** + * Get user theme files (synchronous, from memory only) + * @param {string} themeName - Theme name + * @returns {Map|null} + */ + getUserTheme(themeName) { + const cacheKey = `theme:${themeName}`; + // Check if in memory cache (either from userThemeFiles registration or IndexedDB load) + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey); + } + return null; + } + + /** + * Get user theme files (async, fetches from IndexedDB if not in memory) + * @param {string} themeName - Theme name + * @returns {Promise|null>} + */ + async getUserThemeAsync(themeName) { + // First try synchronous method + const cached = this.getUserTheme(themeName); + if (cached) { + return cached; + } + + // Try to fetch from IndexedDB + if (this.resourceCache) { + try { + const userTheme = await this.resourceCache.getUserTheme(themeName); + if (userTheme) { + const cacheKey = `theme:${themeName}`; + this.cache.set(cacheKey, userTheme.files); + Logger.log(`[ResourceFetcher] User theme '${themeName}' loaded from IndexedDB via getUserThemeAsync`); + return userTheme.files; + } + } catch (e) { + console.warn('[ResourceFetcher] IndexedDB lookup failed:', e.message); + } + } + + return null; + } + + /** + * Load bundle manifest from server or static data * @returns {Promise} */ async loadBundleManifest() { + // Check if running in static mode (prefer capabilities, fallback to direct check) + const capabilities = window.eXeLearning?.app?.capabilities; + const isStaticMode = capabilities + ? !capabilities.storage.remote + : (window.__EXE_STATIC_MODE__ === true || window.eXeLearning?.config?.isStaticMode === true); + + if (isStaticMode) { + // In static mode, use manifest from bundled data + const staticData = window.__EXE_STATIC_DATA__; + if (staticData?.bundleManifest) { + this.bundleManifest = staticData.bundleManifest; + this.bundlesAvailable = true; + console.log('[ResourceFetcher] ✅ Using static bundle manifest'); + return; + } + + // Try to fetch from local bundles/manifest.json + try { + const response = await fetch(`${this.basePath}/bundles/manifest.json`); + if (response.ok) { + this.bundleManifest = await response.json(); + this.bundlesAvailable = true; + console.log('[ResourceFetcher] ✅ Loaded manifest from bundles/manifest.json'); + return; + } + } catch (e) { + console.log('[ResourceFetcher] Static mode: No local manifest.json'); + } + + this.bundlesAvailable = false; + console.log('[ResourceFetcher] Static mode: Using fallback (no bundles)'); + return; + } + + // Server mode: fetch from API try { const manifestUrl = `${this.apiBase}/bundle/manifest`; console.log('[ResourceFetcher] Loading bundle manifest from:', manifestUrl); @@ -187,20 +383,58 @@ class ResourceFetcher { /** * Fetch all files for a theme - * Uses optimized bundle fetching when available, with fallback to individual files. - * @param {string} themeName - Theme name (e.g., 'base', 'blue', 'clean') + * Supports: + * - User themes (from .elpx imports, stored in Yjs via setUserThemeFiles or IndexedDB) + * - Server themes (base/site themes, fetched via bundle or individual files) + * + * Priority order: + * 1. Memory cache (includes user themes registered via setUserThemeFiles) + * 2. userThemeFiles (Yjs) - rebuild cache if needed + * 3. IndexedDB user themes - persistent local storage + * 4. IndexedDB server theme cache - version-based cache + * 5. Server bundles + * 6. Server fallback + * + * @param {string} themeName - Theme name (e.g., 'base', 'blue', 'clean', or user theme) * @returns {Promise>} Map of relative path -> blob */ async fetchTheme(themeName) { const cacheKey = `theme:${themeName}`; - // 1. Check in-memory cache + // 1. Check in-memory cache (includes user themes registered via setUserThemeFiles) if (this.cache.has(cacheKey)) { - Logger.log(`[ResourceFetcher] Theme '${themeName}' loaded from memory cache`); + const isUserTheme = this.userThemeFiles.has(themeName); + Logger.log(`[ResourceFetcher] Theme '${themeName}' loaded from memory cache${isUserTheme ? ' (user theme)' : ''}`); return this.cache.get(cacheKey); } - // 2. Check IndexedDB cache + // 2. User themes from Yjs (registered via setUserThemeFiles) + // If not found in cache at this point, it's not a user theme or hasn't been registered yet + if (this.userThemeFiles.has(themeName)) { + // This shouldn't happen normally - user themes are cached when registered + console.warn(`[ResourceFetcher] User theme '${themeName}' registered but not in cache - rebuilding cache`); + const files = this.userThemeFiles.get(themeName); + await this.setUserThemeFiles(themeName, files); + return this.cache.get(cacheKey); + } + + // 3. Check IndexedDB for user themes (persistent local storage) + if (this.resourceCache) { + try { + const userTheme = await this.resourceCache.getUserTheme(themeName); + if (userTheme) { + // User theme found in IndexedDB + this.cache.set(cacheKey, userTheme.files); + Logger.log(`[ResourceFetcher] User theme '${themeName}' loaded from IndexedDB (${userTheme.files.size} files)`); + return userTheme.files; + } + } catch (e) { + // getUserTheme may throw if method doesn't exist or fails + console.warn('[ResourceFetcher] IndexedDB user theme lookup failed:', e.message); + } + } + + // 4. Check IndexedDB cache for server themes (version-based) if (this.resourceCache) { try { const cached = await this.resourceCache.get('theme', themeName, this.version); @@ -218,9 +452,9 @@ class ResourceFetcher { let themeFiles = null; - // 3. Try ZIP bundle (faster, single request) + // 5. Try ZIP bundle (faster, single request) if (this.bundlesAvailable) { - const bundleUrl = `${this.apiBase}/bundle/theme/${themeName}`; + const bundleUrl = this.getBundleUrl('theme', themeName); console.log(`[ResourceFetcher] 📦 Fetching theme '${themeName}' via bundle:`, bundleUrl); themeFiles = await this.fetchBundle(bundleUrl); if (themeFiles && themeFiles.size > 0) { @@ -228,16 +462,16 @@ class ResourceFetcher { } } - // 4. Fallback to individual file fetches + // 6. Fallback to individual file fetches if (!themeFiles || themeFiles.size === 0) { console.log(`[ResourceFetcher] ⚠️ Falling back to individual file fetches for theme '${themeName}'`); themeFiles = await this.fetchThemeFallback(themeName); } - // 5. Cache the result (cache even if empty to avoid repeated fetches) + // 7. Cache the result (cache even if empty to avoid repeated fetches) this.cache.set(cacheKey, themeFiles); - // Store in IndexedDB for persistence (only if non-empty) + // Store in IndexedDB for persistence (only if non-empty, only for server themes) if (themeFiles.size > 0 && this.resourceCache) { try { await this.resourceCache.set('theme', themeName, this.version, themeFiles); @@ -362,7 +596,7 @@ class ResourceFetcher { * @returns {Promise} */ async loadIdevicesBundle() { - const bundleUrl = `${this.apiBase}/bundle/idevices`; + const bundleUrl = this.getBundleUrl('idevices'); const allFiles = await this.fetchBundle(bundleUrl); if (!allFiles || allFiles.size === 0) { @@ -506,7 +740,7 @@ class ResourceFetcher { // 3. Try ZIP bundle (faster, single request) if (this.bundlesAvailable) { - const bundleUrl = `${this.apiBase}/bundle/libs`; + const bundleUrl = this.getBundleUrl('libs'); libFiles = await this.fetchBundle(bundleUrl); } @@ -761,16 +995,32 @@ class ResourceFetcher { const firstDir = path.split('/')[0]; const isThirdParty = THIRD_PARTY_LIBS.has(firstDir); - // Try the most likely path first (with version for cache busting) - const possiblePaths = isThirdParty - ? [ - `${this.basePath}/${this.version}/libs/${path}`, - `${this.basePath}/${this.version}/app/common/${path}`, - ] - : [ - `${this.basePath}/${this.version}/app/common/${path}`, - `${this.basePath}/${this.version}/libs/${path}`, - ]; + // Build paths based on mode + let possiblePaths; + if (this.isStaticMode()) { + // Static mode: use relative paths without version prefix + const staticBase = this.basePath || '.'; + possiblePaths = isThirdParty + ? [ + `${staticBase}/libs/${path}`, + `${staticBase}/app/common/${path}`, + ] + : [ + `${staticBase}/app/common/${path}`, + `${staticBase}/libs/${path}`, + ]; + } else { + // Server mode: use versioned paths for cache busting + possiblePaths = isThirdParty + ? [ + `${this.basePath}/${this.version}/libs/${path}`, + `${this.basePath}/${this.version}/app/common/${path}`, + ] + : [ + `${this.basePath}/${this.version}/app/common/${path}`, + `${this.basePath}/${this.version}/libs/${path}`, + ]; + } for (const url of possiblePaths) { try { @@ -974,7 +1224,14 @@ class ResourceFetcher { return this.cache.get(cacheKey); } - const logoUrl = `${this.basePath}/${this.version}/app/common/exe_powered_logo/exe_powered_logo.png`; + // Build path based on mode + let logoUrl; + if (this.isStaticMode()) { + const staticBase = this.basePath || '.'; + logoUrl = `${staticBase}/app/common/exe_powered_logo/exe_powered_logo.png`; + } else { + logoUrl = `${this.basePath}/${this.version}/app/common/exe_powered_logo/exe_powered_logo.png`; + } try { const response = await fetch(logoUrl); if (response.ok) { @@ -1026,7 +1283,7 @@ class ResourceFetcher { // 3. Try ZIP bundle if (this.bundlesAvailable) { - const bundleUrl = `${this.apiBase}/bundle/content-css`; + const bundleUrl = this.getBundleUrl('content-css'); cssFiles = await this.fetchBundle(bundleUrl); } diff --git a/public/app/yjs/ResourceFetcher.test.js b/public/app/yjs/ResourceFetcher.test.js index d5a8a7358..c1b16f3aa 100644 --- a/public/app/yjs/ResourceFetcher.test.js +++ b/public/app/yjs/ResourceFetcher.test.js @@ -821,6 +821,299 @@ describe('ResourceFetcher', () => { }); }); + describe('setUserThemeFiles', () => { + it('registers user theme files in userThemeFiles map', async () => { + const fetcher = new ResourceFetcher(); + const files = { + 'style.css': new Uint8Array([99, 115, 115]), + 'config.xml': new Uint8Array([120, 109, 108]), + }; + + await fetcher.setUserThemeFiles('my-theme', files); + + expect(fetcher.userThemeFiles.has('my-theme')).toBe(true); + expect(fetcher.userThemeFiles.get('my-theme')).toBe(files); + }); + + it('converts Uint8Array files to Blob and caches them', async () => { + const fetcher = new ResourceFetcher(); + const files = { + 'style.css': new Uint8Array([99, 115, 115]), + }; + + await fetcher.setUserThemeFiles('my-theme', files); + + const cacheKey = 'theme:my-theme'; + expect(fetcher.cache.has(cacheKey)).toBe(true); + const cached = fetcher.cache.get(cacheKey); + expect(cached).toBeInstanceOf(Map); + expect(cached.has('style.css')).toBe(true); + expect(cached.get('style.css')).toBeInstanceOf(Blob); + }); + + it('detects correct MIME types for various file extensions', async () => { + const fetcher = new ResourceFetcher(); + const files = { + 'style.css': new Uint8Array([1]), + 'script.js': new Uint8Array([1]), + 'data.json': new Uint8Array([1]), + 'page.html': new Uint8Array([1]), + 'config.xml': new Uint8Array([1]), + 'icon.svg': new Uint8Array([1]), + 'image.png': new Uint8Array([1]), + 'photo.jpg': new Uint8Array([1]), + 'photo2.jpeg': new Uint8Array([1]), + 'anim.gif': new Uint8Array([1]), + 'font.woff': new Uint8Array([1]), + 'font2.woff2': new Uint8Array([1]), + 'font3.ttf': new Uint8Array([1]), + 'unknown.xyz': new Uint8Array([1]), + }; + + await fetcher.setUserThemeFiles('test-theme', files); + + const cached = fetcher.cache.get('theme:test-theme'); + expect(cached.get('style.css').type).toBe('text/css'); + expect(cached.get('script.js').type).toBe('application/javascript'); + expect(cached.get('data.json').type).toBe('application/json'); + expect(cached.get('page.html').type).toBe('text/html'); + expect(cached.get('config.xml').type).toBe('text/xml'); + expect(cached.get('icon.svg').type).toBe('image/svg+xml'); + expect(cached.get('image.png').type).toBe('image/png'); + expect(cached.get('photo.jpg').type).toBe('image/jpeg'); + expect(cached.get('photo2.jpeg').type).toBe('image/jpeg'); + expect(cached.get('anim.gif').type).toBe('image/gif'); + expect(cached.get('font.woff').type).toBe('font/woff'); + expect(cached.get('font2.woff2').type).toBe('font/woff2'); + expect(cached.get('font3.ttf').type).toBe('font/ttf'); + expect(cached.get('unknown.xyz').type).toBe('application/octet-stream'); + }); + + it('logs theme registration', async () => { + const fetcher = new ResourceFetcher(); + const files = { + 'style.css': new Uint8Array([1]), + 'script.js': new Uint8Array([1]), + }; + + await fetcher.setUserThemeFiles('logged-theme', files); + + expect(global.Logger.log).toHaveBeenCalledWith( + expect.stringContaining("Registered user theme 'logged-theme' with 2 files") + ); + }); + }); + + describe('hasUserTheme', () => { + it('returns true when user theme is registered', async () => { + const fetcher = new ResourceFetcher(); + const files = { 'style.css': new Uint8Array([1]) }; + + await fetcher.setUserThemeFiles('existing-theme', files); + + expect(fetcher.hasUserTheme('existing-theme')).toBe(true); + }); + + it('returns false when user theme is not registered', () => { + const fetcher = new ResourceFetcher(); + + expect(fetcher.hasUserTheme('non-existent-theme')).toBe(false); + }); + }); + + describe('getUserTheme', () => { + it('returns cached theme files when available', async () => { + const fetcher = new ResourceFetcher(); + const files = { 'style.css': new Uint8Array([1]) }; + + await fetcher.setUserThemeFiles('my-theme', files); + + const result = fetcher.getUserTheme('my-theme'); + + expect(result).toBeInstanceOf(Map); + expect(result.has('style.css')).toBe(true); + }); + + it('returns null when theme not in cache', () => { + const fetcher = new ResourceFetcher(); + + const result = fetcher.getUserTheme('non-existent'); + + expect(result).toBeNull(); + }); + }); + + describe('getUserThemeAsync', () => { + it('returns cached theme files when available in memory', async () => { + const fetcher = new ResourceFetcher(); + const files = { 'style.css': new Uint8Array([1]) }; + + await fetcher.setUserThemeFiles('my-theme', files); + + const result = await fetcher.getUserThemeAsync('my-theme'); + + expect(result).toBeInstanceOf(Map); + expect(result.has('style.css')).toBe(true); + }); + + it('returns null when theme not found anywhere', async () => { + const fetcher = new ResourceFetcher(); + + const result = await fetcher.getUserThemeAsync('non-existent'); + + expect(result).toBeNull(); + }); + + it('fetches from IndexedDB when not in memory but resourceCache available', async () => { + const fetcher = new ResourceFetcher(); + const mockThemeFiles = new Map([['style.css', new Blob(['css'])]]); + const mockResourceCache = { + getUserTheme: vi.fn().mockResolvedValue({ files: mockThemeFiles }), + }; + fetcher.resourceCache = mockResourceCache; + + const result = await fetcher.getUserThemeAsync('db-theme'); + + expect(mockResourceCache.getUserTheme).toHaveBeenCalledWith('db-theme'); + expect(result).toBe(mockThemeFiles); + }); + + it('caches theme in memory after loading from IndexedDB', async () => { + const fetcher = new ResourceFetcher(); + const mockThemeFiles = new Map([['style.css', new Blob(['css'])]]); + const mockResourceCache = { + getUserTheme: vi.fn().mockResolvedValue({ files: mockThemeFiles }), + }; + fetcher.resourceCache = mockResourceCache; + + await fetcher.getUserThemeAsync('db-theme'); + + expect(fetcher.cache.has('theme:db-theme')).toBe(true); + expect(fetcher.cache.get('theme:db-theme')).toBe(mockThemeFiles); + }); + + it('logs when loading from IndexedDB', async () => { + const fetcher = new ResourceFetcher(); + const mockThemeFiles = new Map([['style.css', new Blob(['css'])]]); + const mockResourceCache = { + getUserTheme: vi.fn().mockResolvedValue({ files: mockThemeFiles }), + }; + fetcher.resourceCache = mockResourceCache; + + await fetcher.getUserThemeAsync('db-theme'); + + expect(global.Logger.log).toHaveBeenCalledWith( + expect.stringContaining("User theme 'db-theme' loaded from IndexedDB via getUserThemeAsync") + ); + }); + + it('returns null when IndexedDB has no theme', async () => { + const fetcher = new ResourceFetcher(); + const mockResourceCache = { + getUserTheme: vi.fn().mockResolvedValue(null), + }; + fetcher.resourceCache = mockResourceCache; + + const result = await fetcher.getUserThemeAsync('missing-theme'); + + expect(result).toBeNull(); + }); + + it('handles IndexedDB error gracefully', async () => { + const fetcher = new ResourceFetcher(); + const mockResourceCache = { + getUserTheme: vi.fn().mockRejectedValue(new Error('DB error')), + }; + fetcher.resourceCache = mockResourceCache; + + const result = await fetcher.getUserThemeAsync('error-theme'); + + expect(result).toBeNull(); + expect(console.warn).toHaveBeenCalled(); + }); + }); + + describe('fetchTheme with user themes', () => { + it('returns user theme from memory cache', async () => { + const fetcher = new ResourceFetcher(); + const files = { 'style.css': new Uint8Array([1]) }; + + await fetcher.setUserThemeFiles('user-theme', files); + + const result = await fetcher.fetchTheme('user-theme'); + + expect(result).toBeInstanceOf(Map); + expect(result.has('style.css')).toBe(true); + }); + + it('logs user theme source correctly', async () => { + const fetcher = new ResourceFetcher(); + const files = { 'style.css': new Uint8Array([1]) }; + + await fetcher.setUserThemeFiles('user-theme', files); + global.Logger.log.mockClear(); + + await fetcher.fetchTheme('user-theme'); + + expect(global.Logger.log).toHaveBeenCalledWith( + expect.stringContaining('(user theme)') + ); + }); + + it('rebuilds cache when user theme registered but not in cache', async () => { + const fetcher = new ResourceFetcher(); + const files = { 'style.css': new Uint8Array([1]) }; + + // Register but clear cache to simulate edge case + fetcher.userThemeFiles.set('edge-theme', files); + // Don't call setUserThemeFiles to avoid cache population + + const result = await fetcher.fetchTheme('edge-theme'); + + expect(result).toBeInstanceOf(Map); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('registered but not in cache') + ); + }); + + it('fetches user theme from IndexedDB when not in memory', async () => { + const fetcher = new ResourceFetcher(); + const mockThemeFiles = new Map([['style.css', new Blob(['css'])]]); + const mockResourceCache = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn(), + getUserTheme: vi.fn().mockResolvedValue({ files: mockThemeFiles }), + }; + fetcher.resourceCache = mockResourceCache; + + const result = await fetcher.fetchTheme('indexeddb-theme'); + + expect(mockResourceCache.getUserTheme).toHaveBeenCalledWith('indexeddb-theme'); + expect(result).toBe(mockThemeFiles); + }); + + it('handles IndexedDB user theme lookup error gracefully', async () => { + const fetcher = new ResourceFetcher(); + const mockResourceCache = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn(), + getUserTheme: vi.fn().mockRejectedValue(new Error('DB error')), + }; + fetcher.resourceCache = mockResourceCache; + + // Should fall through to server fetch + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]), + }); + + const result = await fetcher.fetchTheme('error-theme'); + + // The theme fetch should continue even if IndexedDB lookup fails + expect(result).toBeInstanceOf(Map); + }); + }); + describe('loadBundleManifest', () => { it('loads manifest and sets bundlesAvailable to true', async () => { const fetcher = new ResourceFetcher(); diff --git a/public/app/yjs/SaveManager.js b/public/app/yjs/SaveManager.js index 1a700318c..ef48f9c36 100644 --- a/public/app/yjs/SaveManager.js +++ b/public/app/yjs/SaveManager.js @@ -49,6 +49,60 @@ class SaveManager { // WebSocket handler reference for priority signaling this.wsHandler = null; + + // Static mode detection (cached) + this._isStaticMode = null; + } + + /** + * Check if running in static (offline) mode + * @returns {boolean} + */ + isStaticMode() { + if (this._isStaticMode === null) { + // Prefer capabilities check (new pattern) + const capabilities = window.eXeLearning?.app?.capabilities; + if (capabilities) { + // Static mode = no remote storage capability + this._isStaticMode = !capabilities.storage.remote; + } else { + // Fallback to direct detection for early initialization + this._isStaticMode = window.__EXE_STATIC_MODE__ === true || + window.eXeLearning?.config?.isStaticMode === true; + } + } + return this._isStaticMode; + } + + /** + * Handle save in static mode + * In static mode, Yjs auto-saves to IndexedDB. We show a toast + * informing the user to use File > Export to save their project. + * + * @param {Object} options - Save options + * @returns {{success: boolean, message: string}} + */ + async _handleStaticModeSave(options = {}) { + const { silent = false } = options; + + Logger.log('[SaveManager] Static mode: Project is auto-saved to browser storage'); + + if (!silent && eXeLearning?.app?.toasts) { + const toastData = { + title: typeof _ === 'function' ? _('Offline Mode') : 'Offline Mode', + body: typeof _ === 'function' + ? _('Your project is automatically saved in your browser. Use File > Export to download a copy.') + : 'Your project is automatically saved in your browser. Use File > Export to download a copy.', + icon: 'info', + remove: 5000, + }; + eXeLearning.app.toasts.createToast(toastData); + } + + return { + success: true, + message: 'Static mode: Project saved to IndexedDB (use Export to download)', + }; } /** @@ -247,6 +301,11 @@ class SaveManager { async save(options = {}) { const { showProgress = true, silent = false } = options; + // Static mode: Show toast and return success (Yjs auto-saves to IndexedDB) + if (this.isStaticMode()) { + return this._handleStaticModeSave(options); + } + if (this.isSaving) { console.warn('[SaveManager] Save already in progress'); return { success: false, error: 'Save already in progress' }; diff --git a/public/app/yjs/YjsDocumentManager.js b/public/app/yjs/YjsDocumentManager.js index c0362cd6a..44840838e 100644 --- a/public/app/yjs/YjsDocumentManager.js +++ b/public/app/yjs/YjsDocumentManager.js @@ -133,37 +133,126 @@ class YjsDocumentManager { // Setup IndexedDB persistence (offline-first) const dbName = `exelearning-project-${this.projectId}`; - this.indexedDBProvider = new IndexeddbPersistence(dbName, this.ydoc); - // Wait for IndexedDB to sync (with timeout to prevent hanging) - // y-indexeddb may not fire 'synced' event in certain conditions (e.g., rapid reinit) - await new Promise((resolve) => { - let resolved = false; + // Pre-validate IndexedDB schema to avoid runtime errors + // y-indexeddb expects specific object stores, and corrupted/old databases can cause errors + const isDbValid = await this._validateIndexedDb(dbName); + if (!isDbValid) { + Logger.warn(`[YjsDocumentManager] IndexedDB ${dbName} has invalid schema, deleting...`); + try { + await new Promise((resolve, reject) => { + const deleteReq = indexedDB.deleteDatabase(dbName); + deleteReq.onsuccess = () => resolve(); + deleteReq.onerror = () => reject(deleteReq.error); + deleteReq.onblocked = () => resolve(); // Proceed anyway + }); + Logger.log(`[YjsDocumentManager] Deleted invalid database ${dbName}`); + } catch (e) { + Logger.warn(`[YjsDocumentManager] Failed to delete invalid database:`, e); + } + } - const onSynced = () => { - if (resolved) return; - resolved = true; - Logger.log(`[YjsDocumentManager] Synced from IndexedDB for project ${this.projectId}`); - resolve(); - }; + // Try to create IndexedDB provider with error recovery + try { + this.indexedDBProvider = new IndexeddbPersistence(dbName, this.ydoc); + + // Add persistent error handler for runtime errors (e.g., corrupted schema) + // This catches errors that occur during writes, not just initialization + this.indexedDBProvider.on('error', async (error) => { + Logger.warn(`[YjsDocumentManager] IndexedDB runtime error for project ${this.projectId}:`, error); + // If error is about missing object stores, the database schema is corrupted + if (error?.name === 'NotFoundError' || error?.message?.includes('object stores')) { + Logger.warn('[YjsDocumentManager] Database schema appears corrupted, disabling persistence'); + // Destroy the provider to prevent further errors + if (this.indexedDBProvider) { + try { + await this.indexedDBProvider.destroy(); + } catch (e) { + // Ignore destroy errors + } + this.indexedDBProvider = null; + } + } + }); - // Check if already synced (may happen for empty/new databases) - if (this.indexedDBProvider.synced) { - onSynced(); - return; - } + // Wait for IndexedDB to sync (with timeout to prevent hanging) + // y-indexeddb may not fire 'synced' event in certain conditions (e.g., rapid reinit) + await new Promise((resolve, reject) => { + let resolved = false; - // Listen for synced event - this.indexedDBProvider.on('synced', onSynced); + const onSynced = () => { + if (resolved) return; + resolved = true; + Logger.log(`[YjsDocumentManager] Synced from IndexedDB for project ${this.projectId}`); + resolve(); + }; - // Timeout after 3 seconds - IndexedDB sync should be fast - setTimeout(() => { - if (resolved) return; - resolved = true; - Logger.log(`[YjsDocumentManager] IndexedDB sync timeout for project ${this.projectId}, proceeding anyway`); - resolve(); - }, 3000); - }); + // Handle errors during sync + this.indexedDBProvider.on('error', (error) => { + if (resolved) return; + resolved = true; + Logger.warn(`[YjsDocumentManager] IndexedDB error for project ${this.projectId}:`, error); + reject(error); + }); + + // Check if already synced (may happen for empty/new databases) + if (this.indexedDBProvider.synced) { + onSynced(); + return; + } + + // Listen for synced event + this.indexedDBProvider.on('synced', onSynced); + + // Timeout after 3 seconds - IndexedDB sync should be fast + setTimeout(() => { + if (resolved) return; + resolved = true; + Logger.log(`[YjsDocumentManager] IndexedDB sync timeout for project ${this.projectId}, proceeding anyway`); + resolve(); + }, 3000); + }); + } catch (indexedDbError) { + Logger.warn(`[YjsDocumentManager] IndexedDB initialization failed for project ${this.projectId}:`, indexedDbError); + Logger.warn('[YjsDocumentManager] Attempting to clear corrupted database and retry...'); + + // Try to delete the corrupted database + try { + const deleteRequest = indexedDB.deleteDatabase(dbName); + await new Promise((resolve, reject) => { + deleteRequest.onsuccess = () => { + Logger.log(`[YjsDocumentManager] Deleted corrupted database ${dbName}`); + resolve(); + }; + deleteRequest.onerror = () => reject(deleteRequest.error); + deleteRequest.onblocked = () => { + Logger.warn(`[YjsDocumentManager] Database deletion blocked for ${dbName}`); + resolve(); // Proceed anyway + }; + }); + + // Retry with fresh database + this.indexedDBProvider = new IndexeddbPersistence(dbName, this.ydoc); + + // Wait for sync with shorter timeout + await new Promise((resolve) => { + const timeout = setTimeout(() => resolve(), 2000); + this.indexedDBProvider.on('synced', () => { + clearTimeout(timeout); + Logger.log(`[YjsDocumentManager] Synced from fresh IndexedDB for project ${this.projectId}`); + resolve(); + }); + if (this.indexedDBProvider.synced) { + clearTimeout(timeout); + resolve(); + } + }); + } catch (deleteError) { + Logger.error(`[YjsDocumentManager] Failed to recover IndexedDB for project ${this.projectId}:`, deleteError); + // Proceed without IndexedDB persistence - data will only be in memory + this.indexedDBProvider = null; + } + } // Setup WebSocket provider (but don't connect yet) // Connection happens later via startWebSocketConnection() after message handlers are installed @@ -190,7 +279,22 @@ class YjsDocumentManager { // This prevents duplicate pages when multiple clients join simultaneously if (this.config.offline && navigation.length === 0) { Logger.log('[YjsDocumentManager] Creating blank project structure (offline mode)'); - this.createBlankProjectStructure(); + try { + this.createBlankProjectStructure(); + } catch (createError) { + // IndexedDB may throw if database schema is corrupted + Logger.warn('[YjsDocumentManager] Error creating blank structure, disabling IndexedDB:', createError); + if (this.indexedDBProvider) { + try { + await this.indexedDBProvider.destroy(); + } catch (e) { + // Ignore + } + this.indexedDBProvider = null; + } + // Retry without persistence + this.createBlankProjectStructure(); + } } // For online mode, YjsProjectBridge will call ensureBlankStructureIfEmpty() after sync } @@ -486,6 +590,62 @@ class YjsDocumentManager { }); } + /** + * Validate IndexedDB schema before using it + * y-indexeddb expects 'updates' and 'custom' object stores + * @param {string} dbName - Database name to validate + * @returns {Promise} true if valid or doesn't exist, false if invalid schema + * @private + */ + async _validateIndexedDb(dbName) { + return new Promise((resolve) => { + // Check if IndexedDB is available + if (!window.indexedDB) { + resolve(true); // No IndexedDB, let the provider handle it + return; + } + + // Try to open the database without specifying version (use existing version) + const openReq = indexedDB.open(dbName); + + openReq.onerror = () => { + // If we can't open, let the provider try to create it fresh + resolve(true); + }; + + openReq.onsuccess = () => { + const db = openReq.result; + try { + // y-indexeddb requires 'updates' object store (and optionally 'custom') + const hasUpdates = db.objectStoreNames.contains('updates'); + db.close(); + + if (!hasUpdates) { + // Database exists but doesn't have required object stores + Logger.warn(`[YjsDocumentManager] Database ${dbName} missing 'updates' object store`); + resolve(false); + return; + } + + resolve(true); + } catch (e) { + db.close(); + resolve(false); + } + }; + + openReq.onupgradeneeded = () => { + // Database doesn't exist yet or needs upgrade - this is fine + // Cancel the upgrade and let y-indexeddb handle creation + openReq.transaction?.abort(); + resolve(true); + }; + + // Timeout in case something hangs + setTimeout(() => resolve(true), 2000); + }); + } + /** * Connect to y-websocket server for real-time collaboration * Uses y-websocket's WebsocketProvider which handles: @@ -956,6 +1116,21 @@ class YjsDocumentManager { return this.ydoc.getMap('assets'); } + /** + * Get theme files map - stores user theme files imported from .elpx + * Structure: Map> + * Example: themeFiles.get('universal') -> Map { 'style.css' -> '...base64...', 'config.xml' -> '...' } + * + * User themes imported from .elpx files are stored client-side in Yjs, + * not on the server. This simplifies the architecture and allows + * themes to sync automatically between collaborators. + * @returns {Y.Map} + */ + getThemeFiles() { + this._ensureInitialized(); + return this.ydoc.getMap('themeFiles'); + } + /** * Get the raw Y.Doc * @returns {Y.Doc} diff --git a/public/app/yjs/YjsDocumentManager.test.js b/public/app/yjs/YjsDocumentManager.test.js index e45b601f1..56a73dfe6 100644 --- a/public/app/yjs/YjsDocumentManager.test.js +++ b/public/app/yjs/YjsDocumentManager.test.js @@ -330,7 +330,7 @@ describe('YjsDocumentManager', () => { }); }); - describe('getNavigation / getMetadata / getLocks / getDoc', () => { + describe('getNavigation / getMetadata / getLocks / getDoc / getThemeFiles', () => { beforeEach(async () => { await manager.initialize(); }); @@ -358,6 +358,23 @@ describe('YjsDocumentManager', () => { expect(doc).toBeDefined(); expect(doc).toBeInstanceOf(global.window.Y.Doc); }); + + it('getThemeFiles returns Y.Map for user themes', () => { + const themeFiles = manager.getThemeFiles(); + expect(themeFiles).toBeDefined(); + expect(themeFiles).toBeInstanceOf(global.window.Y.Map); + }); + + it('getThemeFiles throws when not initialized', () => { + const uninitManager = new YjsDocumentManager('uninitialized-project', { + wsUrl: 'ws://localhost:3001/yjs', + apiUrl: '/api', + token: 'test-token', + offline: true, + }); + + expect(() => uninitManager.getThemeFiles()).toThrow('YjsDocumentManager not initialized'); + }); }); describe('dirty state management', () => { diff --git a/public/app/yjs/YjsProjectBridge.js b/public/app/yjs/YjsProjectBridge.js index 1472388f8..7e671ae5d 100644 --- a/public/app/yjs/YjsProjectBridge.js +++ b/public/app/yjs/YjsProjectBridge.js @@ -25,6 +25,7 @@ class YjsProjectBridge { this.assetCache = null; // Legacy - kept for backward compatibility this.assetManager = null; // New asset manager with asset:// URLs this.resourceFetcher = null; // ResourceFetcher for fetching themes, libs, iDevices + this.resourceCache = null; // ResourceCache for persistent IndexedDB storage (themes, libs, iDevices) this.assetWebSocketHandler = null; // WebSocket handler for peer-to-peer asset sync this.saveManager = null; // SaveManager for saving to server with progress this.connectionMonitor = null; // ConnectionMonitor for connection failure handling @@ -102,19 +103,18 @@ class YjsProjectBridge { this.assetCache = new window.AssetCacheManager(projectId); // Create ResourceCache for persistent caching of themes, libraries, iDevices - let resourceCache = null; if (window.ResourceCache) { - resourceCache = new window.ResourceCache(); + this.resourceCache = new window.ResourceCache(); try { - await resourceCache.init(); + await this.resourceCache.init(); Logger.log('[YjsProjectBridge] ResourceCache initialized'); // Clean old version entries on startup const currentVersion = window.eXeLearning?.version || 'v0.0.0'; - await resourceCache.clearOldVersions(currentVersion); + await this.resourceCache.clearOldVersions(currentVersion); } catch (e) { console.warn('[YjsProjectBridge] ResourceCache initialization failed:', e); - resourceCache = null; + this.resourceCache = null; } } @@ -122,7 +122,11 @@ class YjsProjectBridge { if (window.ResourceFetcher) { this.resourceFetcher = new window.ResourceFetcher(); // Initialize with ResourceCache for persistent caching - await this.resourceFetcher.init(resourceCache); + await this.resourceFetcher.init(this.resourceCache); + // Also expose on eXeLearning.app for access from Theme class + if (this.app) { + this.app.resourceFetcher = this.resourceFetcher; + } Logger.log('[YjsProjectBridge] ResourceFetcher initialized with bundle support'); } @@ -211,6 +215,27 @@ class YjsProjectBridge { // Trigger initial structure load for observers (in case blank structure was created) this.triggerInitialStructureLoad(); + // Load user themes from Yjs (for collaborator sync and project re-open) + await this.loadUserThemesFromYjs(); + + // Load user themes from IndexedDB (global themes that persist across projects) + // Pass resourceCache directly since _yjsBridge may not be set on the project yet + if (eXeLearning.app?.themes?.list?.loadUserThemesFromIndexedDB) { + try { + // Pass this.resourceCache directly to avoid timing issues with _yjsBridge reference + await eXeLearning.app.themes.list.loadUserThemesFromIndexedDB(this.resourceCache); + } catch (err) { + console.error('[YjsProjectBridge] loadUserThemesFromIndexedDB error:', err); + } + // Refresh NavbarStyles UI to show loaded themes + if (eXeLearning.app.menus?.navbar?.styles) { + eXeLearning.app.menus.navbar.styles.updateThemes(); + } + } + + // Set up observer for theme files changes (collaborator theme sync) + this.setupThemeFilesObserver(); + return this; } @@ -1809,15 +1834,20 @@ class YjsProjectBridge { const stats = await importer.importFromFile(file, options); // Announce imported assets to server for peer-to-peer collaboration - if (stats && stats.assets > 0) { + // Skip only when collaboration is explicitly disabled (capabilities available and disabled) + const capabilities = window.eXeLearning?.app?.capabilities; + const collaborationEnabled = !capabilities || capabilities.collaboration?.enabled; + if (stats && stats.assets > 0 && collaborationEnabled) { Logger.log(`[YjsProjectBridge] Announcing ${stats.assets} imported assets to peers...`); await this.announceAssets(); } // Check and handle theme from imported package // Only import theme when opening a file (clearExisting=true), not when importing into existing project + // Skip only when remote storage is explicitly disabled (capabilities available and disabled) const clearExisting = options.clearExisting !== false; // default is true - if (stats && stats.theme && clearExisting) { + const hasRemoteStorage = !capabilities || capabilities.storage?.remote; + if (stats && stats.theme && clearExisting && hasRemoteStorage) { await this._checkAndImportTheme(stats.theme, file); } @@ -1834,6 +1864,11 @@ class YjsProjectBridge { * theme will be used. Administrators should be aware that enabling this feature * allows users to run custom JavaScript in exported content. * + * Priority for finding themes: + * 1. Server themes (base/site) - always available + * 2. IndexedDB user themes - persistent local storage + * 3. Package theme folder - requires user confirmation + * * @param {string} themeName - Name of the theme from the package * @param {File} file - The original .elpx file to check for /theme/ folder * @private @@ -1850,18 +1885,35 @@ class YjsProjectBridge { if (!isOfflineInstallation && !userStylesEnabled) { Logger.log('[YjsProjectBridge] Theme import disabled (ONLINE_THEMES_INSTALL=0), using default theme'); - eXeLearning.app.themes.selectTheme(eXeLearning.config.defaultTheme, false); + // Save=true to update Yjs metadata with default theme (replacing imported theme) + eXeLearning.app.themes.selectTheme(eXeLearning.config.defaultTheme, true); return; } - // Check if theme is installed + // 1. Check if theme is installed on server (base/site themes) const installedThemes = eXeLearning.app.themes?.list?.installed || {}; if (Object.keys(installedThemes).includes(themeName)) { - Logger.log(`[YjsProjectBridge] Theme "${themeName}" already installed, selecting it`); + Logger.log(`[YjsProjectBridge] Theme "${themeName}" already installed (server), selecting it`); await eXeLearning.app.themes.selectTheme(themeName, true); return; } + // 2. Check if theme exists in IndexedDB (persistent user themes) + if (this.resourceCache) { + try { + const hasUserTheme = await this.resourceCache.hasUserTheme(themeName); + if (hasUserTheme) { + Logger.log(`[YjsProjectBridge] Theme "${themeName}" found in IndexedDB, loading it`); + // Load theme from IndexedDB and register + await this._loadUserThemeFromIndexedDB(themeName); + await eXeLearning.app.themes.selectTheme(themeName, true); + return; + } + } catch (e) { + console.warn('[YjsProjectBridge] Error checking IndexedDB for theme:', e); + } + } + // Theme not installed - check if package has /theme/ folder try { const fflateLib = window.fflate; @@ -1875,7 +1927,8 @@ class YjsProjectBridge { if (!themeConfig) { Logger.log(`[YjsProjectBridge] No theme folder in package, using default`); - eXeLearning.app.themes.selectTheme(eXeLearning.config.defaultTheme, false); + // Save=true to update Yjs metadata with default theme (replacing imported theme) + eXeLearning.app.themes.selectTheme(eXeLearning.config.defaultTheme, true); return; } @@ -1887,12 +1940,16 @@ class YjsProjectBridge { this._showThemeImportModal(themeName); } catch (error) { console.error('[YjsProjectBridge] Error checking theme in package:', error); - eXeLearning.app.themes.selectTheme(eXeLearning.config.defaultTheme, false); + // Save=true to update Yjs metadata with default theme (replacing imported theme) + eXeLearning.app.themes.selectTheme(eXeLearning.config.defaultTheme, true); } } /** * Show modal to confirm theme import + * User themes from .elpx files are stored: + * 1. IndexedDB (persistent local storage, available across all projects) + * 2. Yjs (compressed ZIP, for collaboration and export) * @param {string} themeName - Name of the theme to import * @private */ @@ -1906,36 +1963,55 @@ class YjsProjectBridge { body: text, confirmExec: async () => { try { - // Package theme files from the stored ZIP - const themeZip = await this._packageThemeFromZip(themeName); - if (!themeZip) { + // Extract theme files from the stored ZIP + const themeFilesData = this._extractThemeFilesFromZip(); + if (!themeFilesData || Object.keys(themeFilesData.files).length === 0) { throw new Error('Could not extract theme files from package'); } - const params = { - themeDirname: themeName, - themeZip: themeZip, - }; - Logger.log('[YjsProjectBridge] Importing theme:', themeName); - const response = await eXeLearning.app.api.postOdeImportTheme(params); + + // Parse config.xml to create theme configuration + const themeConfig = this._parseThemeConfigFromFiles(themeName, themeFilesData); + if (!themeConfig) { + throw new Error('Could not parse theme configuration'); + } + + // 1. Compress theme files and save to IndexedDB (persistent local storage) + if (this.resourceCache) { + const compressedFiles = this._compressThemeFiles(themeFilesData.files); + await this.resourceCache.setUserTheme(themeName, compressedFiles, themeConfig); + Logger.log(`[YjsProjectBridge] Saved theme to IndexedDB: ${themeName}`); + } + + // 2. Copy compressed theme to Yjs for collaboration/export + await this._copyThemeToYjs(themeName, themeFilesData.files); + + // 3. Register theme files with ResourceFetcher for export and preview + if (this.resourceFetcher) { + await this.resourceFetcher.setUserThemeFiles(themeName, themeFilesData.files); + } + + // 4. Add theme to local installed list + eXeLearning.app.themes.list.addUserTheme(themeConfig); + + // 5. Refresh NavbarStyles UI to show the new theme immediately + if (eXeLearning.app.menus?.navbar?.styles) { + eXeLearning.app.menus.navbar.styles.updateThemes(); + // If styles panel is open, rebuild the list + const stylesPanel = document.getElementById('stylessidenav'); + if (stylesPanel?.classList.contains('active')) { + eXeLearning.app.menus.navbar.styles.buildUserListThemes(); + } + } // Clean up stored references this._pendingThemeFile = null; this._pendingThemeZip = null; - if (response.responseMessage === 'OK' && response.themes) { - // Reload theme list and select imported theme - eXeLearning.app.themes.list.loadThemes(response.themes.themes); - await eXeLearning.app.themes.selectTheme(themeName, true); - Logger.log(`[YjsProjectBridge] Theme "${themeName}" imported successfully`); - } else { - console.error('[YjsProjectBridge] Theme import failed:', response.responseMessage || response.error); - eXeLearning.app.modals.alert.show({ - title: _('Error'), - body: response.error || response.responseMessage || _('Failed to import style'), - }); - } + // Select the theme and save to metadata + await eXeLearning.app.themes.selectTheme(themeName, true); + Logger.log(`[YjsProjectBridge] Theme "${themeName}" imported successfully`); } catch (error) { console.error('[YjsProjectBridge] Theme import error:', error); // Clean up stored references @@ -1951,19 +2027,18 @@ class YjsProjectBridge { // Clean up stored references this._pendingThemeFile = null; this._pendingThemeZip = null; - // Use default theme - eXeLearning.app.themes.selectTheme(eXeLearning.config.defaultTheme, false); + // Use default theme and save to Yjs (replacing imported theme in metadata) + eXeLearning.app.themes.selectTheme(eXeLearning.config.defaultTheme, true); }, }); } /** - * Package theme files from stored ZIP into a new ZIP blob - * @param {string} themeName - Name of the theme - * @returns {Promise} Theme ZIP blob or null if failed + * Extract theme files from stored ZIP + * @returns {{files: Object, configXml: string|null}|null} * @private */ - async _packageThemeFromZip(themeName) { + _extractThemeFilesFromZip() { try { const zip = this._pendingThemeZip; if (!zip) { @@ -1971,40 +2046,423 @@ class YjsProjectBridge { return null; } - const fflateLib = window.fflate; - if (!fflateLib) { - console.error('[YjsProjectBridge] fflate library not loaded'); - return null; - } - // Extract all files from theme/ folder - const themeFiles = {}; + const files = {}; + let configXml = null; + for (const [filePath, fileData] of Object.entries(zip)) { if (filePath.startsWith('theme/') && !filePath.endsWith('/')) { // Remove 'theme/' prefix to get relative path const relativePath = filePath.substring(6); // 'theme/'.length = 6 if (relativePath) { - themeFiles[relativePath] = fileData; + files[relativePath] = fileData; + // Capture config.xml content + if (relativePath === 'config.xml') { + configXml = new TextDecoder().decode(fileData); + } } } } - if (Object.keys(themeFiles).length === 0) { + if (Object.keys(files).length === 0) { console.error('[YjsProjectBridge] No theme files found in package'); return null; } - Logger.log(`[YjsProjectBridge] Packaging ${Object.keys(themeFiles).length} theme files`); + Logger.log(`[YjsProjectBridge] Extracted ${Object.keys(files).length} theme files`); + return { files, configXml }; + } catch (error) { + console.error('[YjsProjectBridge] Error extracting theme:', error); + return null; + } + } + + /** + * Parse theme configuration from extracted files + * @param {string} themeName - Theme name/directory + * @param {{files: Object, configXml: string|null}} themeFilesData + * @returns {Object|null} Theme configuration object + * @private + */ + _parseThemeConfigFromFiles(themeName, themeFilesData) { + try { + const { files, configXml } = themeFilesData; + + // Default config values + const config = { + name: themeName, + dirName: themeName, + displayName: themeName, + title: themeName, + type: 'user', // User themes from .elpx + version: '1.0', + author: '', + license: '', + description: '', + cssFiles: [], + js: [], + icons: {}, + valid: true, + isUserTheme: true, // Flag to indicate this is a client-side theme + }; - // Create ZIP - const zipped = fflateLib.zipSync(themeFiles); - return new Blob([zipped], { type: 'application/zip' }); + // Parse config.xml if available + if (configXml) { + const getValue = (tag) => { + const match = configXml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`)); + return match ? match[1].trim() : ''; + }; + + config.name = getValue('name') || themeName; + config.displayName = getValue('name') || themeName; + config.title = getValue('name') || themeName; + config.version = getValue('version') || '1.0'; + config.author = getValue('author') || ''; + config.license = getValue('license') || ''; + config.description = getValue('description') || ''; + } + + // Scan for CSS files + for (const filePath of Object.keys(files)) { + if (filePath.endsWith('.css') && !filePath.includes('/')) { + config.cssFiles.push(filePath); + } + } + if (config.cssFiles.length === 0) { + config.cssFiles.push('style.css'); + } + + // Scan for JS files + for (const filePath of Object.keys(files)) { + if (filePath.endsWith('.js') && !filePath.includes('/')) { + config.js.push(filePath); + } + } + + // Scan for icons + for (const filePath of Object.keys(files)) { + if (filePath.startsWith('icons/') && (filePath.endsWith('.png') || filePath.endsWith('.svg'))) { + const iconName = filePath.replace('icons/', '').replace(/\.(png|svg)$/, ''); + config.icons[iconName] = filePath; + } + } + + return config; } catch (error) { - console.error('[YjsProjectBridge] Error packaging theme:', error); + console.error('[YjsProjectBridge] Error parsing theme config:', error); return null; } } + /** + * Convert Uint8Array to base64 string + * @param {Uint8Array} uint8Array + * @returns {string} + * @private + */ + _uint8ArrayToBase64(uint8Array) { + let binary = ''; + const len = uint8Array.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(uint8Array[i]); + } + return btoa(binary); + } + + /** + * Convert base64 string to Uint8Array + * @param {string} base64 + * @returns {Uint8Array} + * @private + */ + _base64ToUint8Array(base64) { + const binary = atob(base64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + + /** + * Compress theme files to ZIP format using fflate + * @param {Object} files - Map of relativePath -> file content + * @returns {Uint8Array} Compressed ZIP data + * @private + */ + _compressThemeFiles(files) { + if (!window.fflate) { + throw new Error('fflate library not loaded'); + } + + // fflate.zipSync expects {filename: Uint8Array} format + const toCompress = {}; + for (const [path, uint8Array] of Object.entries(files)) { + toCompress[path] = uint8Array; + } + + return window.fflate.zipSync(toCompress, { level: 6 }); + } + + /** + * Copy theme to Yjs for collaboration and export + * Stores the theme as a compressed ZIP in base64 + * @param {string} themeName - Theme name + * @param {Object} files - Map of relativePath -> file content + * @private + */ + async _copyThemeToYjs(themeName, files) { + try { + const themeFilesMap = this.documentManager.getThemeFiles(); + + // Compress files to ZIP + const compressed = this._compressThemeFiles(files); + + // Convert to base64 for Yjs storage + const base64Compressed = this._uint8ArrayToBase64(compressed); + + // Store as single compressed string in Yjs (NOT a Y.Map with individual files) + themeFilesMap.set(themeName, base64Compressed); + + Logger.log(`[YjsProjectBridge] Copied theme '${themeName}' to Yjs (${Math.round(compressed.length / 1024)}KB compressed)`); + } catch (error) { + console.error(`[YjsProjectBridge] Error copying theme to Yjs:`, error); + throw error; + } + } + + /** + * Load a user theme from IndexedDB and register it + * @param {string} themeName - Theme name + * @private + */ + async _loadUserThemeFromIndexedDB(themeName) { + try { + if (!this.resourceCache) { + throw new Error('ResourceCache not initialized'); + } + + const userTheme = await this.resourceCache.getUserTheme(themeName); + if (!userTheme) { + throw new Error(`Theme '${themeName}' not found in IndexedDB`); + } + + const { files, config } = userTheme; + + // Convert Map to Object for ResourceFetcher + const filesObject = {}; + for (const [path, blob] of files) { + const arrayBuffer = await blob.arrayBuffer(); + filesObject[path] = new Uint8Array(arrayBuffer); + } + + // Register with ResourceFetcher + if (this.resourceFetcher) { + await this.resourceFetcher.setUserThemeFiles(themeName, filesObject); + } + + // Add to installed themes if not already there + if (eXeLearning.app?.themes?.list?.installed && !eXeLearning.app.themes.list.installed[themeName]) { + eXeLearning.app.themes.list.addUserTheme(config); + } + + Logger.log(`[YjsProjectBridge] Loaded user theme '${themeName}' from IndexedDB`); + } catch (error) { + console.error(`[YjsProjectBridge] Error loading theme from IndexedDB:`, error); + throw error; + } + } + + /** + * Load user themes from Yjs into ResourceFetcher and theme list + * This is called on initialization to restore user themes for: + * - Reopening a project with user themes + * - Joining a collaborative session where another user imported a theme + * + * Priority: + * 1. Check if theme exists in local IndexedDB - use that if available + * 2. If not in IndexedDB, decompress from Yjs and save to IndexedDB + */ + async loadUserThemesFromYjs() { + try { + const themeFilesMap = this.documentManager.getThemeFiles(); + if (!themeFilesMap || themeFilesMap.size === 0) { + Logger.log('[YjsProjectBridge] No user themes in Yjs to load'); + return; + } + + Logger.log(`[YjsProjectBridge] Loading ${themeFilesMap.size} user theme(s) from Yjs...`); + + // Iterate over each theme in the themeFiles map + for (const [themeName, themeData] of themeFilesMap.entries()) { + await this._loadUserThemeFromYjs(themeName, themeData); + } + } catch (error) { + console.error('[YjsProjectBridge] Error loading user themes from Yjs:', error); + } + } + + /** + * Load a single user theme from Yjs + * Handles both new compressed format (base64 ZIP) and legacy format (Y.Map) + * + * @param {string} themeName - Theme name + * @param {string|Y.Map} themeData - Either base64 compressed ZIP (new) or Y.Map (legacy) + * @private + */ + async _loadUserThemeFromYjs(themeName, themeData) { + try { + // 1. Check if theme is already loaded in ResourceFetcher (memory) + if (this.resourceFetcher?.hasUserTheme(themeName)) { + Logger.log(`[YjsProjectBridge] User theme '${themeName}' already loaded in memory`); + return; + } + + // 2. Check if theme exists in IndexedDB - load from there if available + if (this.resourceCache) { + try { + const hasInIndexedDB = await this.resourceCache.hasUserTheme(themeName); + if (hasInIndexedDB) { + Logger.log(`[YjsProjectBridge] User theme '${themeName}' found in IndexedDB, loading from there`); + await this._loadUserThemeFromIndexedDB(themeName); + return; + } + } catch (e) { + console.warn(`[YjsProjectBridge] Error checking IndexedDB for theme '${themeName}':`, e); + } + } + + // 3. Theme not in IndexedDB - extract from Yjs + let files = {}; + let configXml = null; + + // Check if new compressed format (base64 string) or legacy format (Y.Map) + if (typeof themeData === 'string') { + // New compressed format - decompress ZIP + const decompressed = this._decompressThemeFromYjs(themeData); + files = decompressed.files; + configXml = decompressed.configXml; + } else if (themeData && typeof themeData.entries === 'function') { + // Legacy format - Y.Map with individual base64 files + for (const [relativePath, base64Content] of themeData.entries()) { + const uint8Array = this._base64ToUint8Array(base64Content); + files[relativePath] = uint8Array; + if (relativePath === 'config.xml') { + configXml = new TextDecoder().decode(uint8Array); + } + } + } else { + Logger.log(`[YjsProjectBridge] Unknown theme data format for '${themeName}', skipping`); + return; + } + + if (Object.keys(files).length === 0) { + Logger.log(`[YjsProjectBridge] User theme '${themeName}' has no files, skipping`); + return; + } + + Logger.log(`[YjsProjectBridge] Extracted ${Object.keys(files).length} files for user theme '${themeName}' from Yjs`); + + // Parse theme configuration + const themeConfig = this._parseThemeConfigFromFiles(themeName, { files, configXml }); + if (!themeConfig) { + console.warn(`[YjsProjectBridge] Could not parse config for theme '${themeName}'`); + return; + } + + // 4. Save to IndexedDB for persistence (so we don't need to extract from Yjs again) + if (this.resourceCache) { + try { + const compressedFiles = this._compressThemeFiles(files); + await this.resourceCache.setUserTheme(themeName, compressedFiles, themeConfig); + Logger.log(`[YjsProjectBridge] Saved theme '${themeName}' to IndexedDB`); + } catch (e) { + console.warn(`[YjsProjectBridge] Could not save theme '${themeName}' to IndexedDB:`, e); + } + } + + // 5. Register with ResourceFetcher + if (this.resourceFetcher) { + await this.resourceFetcher.setUserThemeFiles(themeName, files); + } + + // 6. Add to installed themes if not already there + if (eXeLearning.app?.themes?.list?.installed && !eXeLearning.app.themes.list.installed[themeName]) { + eXeLearning.app.themes.list.addUserTheme(themeConfig); + Logger.log(`[YjsProjectBridge] Added user theme '${themeName}' to installed themes`); + } + } catch (error) { + console.error(`[YjsProjectBridge] Error loading user theme '${themeName}':`, error); + } + } + + /** + * Decompress theme files from Yjs (base64 ZIP format) + * @param {string} base64Compressed - Base64 encoded ZIP data + * @returns {{files: Object, configXml: string|null}} + * @private + */ + _decompressThemeFromYjs(base64Compressed) { + if (!window.fflate) { + throw new Error('fflate library not loaded'); + } + + // Decode base64 to Uint8Array + const compressed = this._base64ToUint8Array(base64Compressed); + + // Decompress ZIP + const decompressed = window.fflate.unzipSync(compressed); + + const files = {}; + let configXml = null; + + for (const [path, data] of Object.entries(decompressed)) { + files[path] = data; + if (path === 'config.xml') { + configXml = new TextDecoder().decode(data); + } + } + + return { files, configXml }; + } + + /** + * Set up observer for theme files changes (for collaborator sync) + * When a collaborator imports a theme, this observer will load it locally + * and save it to IndexedDB for persistence + */ + setupThemeFilesObserver() { + try { + const themeFilesMap = this.documentManager.getThemeFiles(); + + themeFilesMap.observe(async (event) => { + // Process added themes + for (const [themeName, change] of event.changes.keys) { + if (change.action === 'add') { + const themeData = themeFilesMap.get(themeName); + if (themeData) { + Logger.log(`[YjsProjectBridge] Collaborator added theme '${themeName}', loading...`); + await this._loadUserThemeFromYjs(themeName, themeData); + } + } else if (change.action === 'delete') { + Logger.log(`[YjsProjectBridge] Theme '${themeName}' removed from Yjs`); + // Theme was removed - we leave it in IndexedDB (user may want to keep it) + // But we should remove it from ResourceFetcher cache + if (this.resourceFetcher?.userThemeFiles) { + this.resourceFetcher.userThemeFiles.delete(themeName); + this.resourceFetcher.cache.delete(`theme:${themeName}`); + } + } + } + }); + + Logger.log('[YjsProjectBridge] Theme files observer set up'); + } catch (error) { + console.error('[YjsProjectBridge] Error setting up theme files observer:', error); + } + } + /** * Get the AssetManager instance * @returns {AssetManager|null} diff --git a/public/app/yjs/YjsProjectBridge.test.js b/public/app/yjs/YjsProjectBridge.test.js index 1a5c4b9b7..be4cbc99f 100644 --- a/public/app/yjs/YjsProjectBridge.test.js +++ b/public/app/yjs/YjsProjectBridge.test.js @@ -169,6 +169,20 @@ describe('YjsProjectBridge', () => { ResourceCache: MockResourceCache, eXeLearning: { config: { basePath: '' }, + app: { + themes: { + list: { + loadUserThemesFromIndexedDB: mock(async () => {}), + }, + }, + menus: { + navbar: { + styles: { + updateThemes: mock(() => {}), + }, + }, + }, + }, }, location: { protocol: 'http:', @@ -177,6 +191,8 @@ describe('YjsProjectBridge', () => { origin: 'http://localhost:3001', }, }; + // Also set eXeLearning globally since the code accesses it directly + global.eXeLearning = global.window.eXeLearning; global.document = { createElement: mock(() => ({ @@ -520,8 +536,8 @@ describe('YjsProjectBridge', () => { await bridge._checkAndImportTheme('unknown-theme', mockFile); - // selectTheme should be called with default theme (fallback) - expect(mockSelectTheme).toHaveBeenCalledWith('base', false); + // selectTheme should be called with default theme (fallback) and save=true to update Yjs + expect(mockSelectTheme).toHaveBeenCalledWith('base', true); }); it('should return early if themeName is empty', async () => { @@ -563,8 +579,8 @@ describe('YjsProjectBridge', () => { await bridge._checkAndImportTheme('custom-theme', new Blob()); - // Should use default theme immediately without prompting - expect(mockSelectTheme).toHaveBeenCalledWith('base', false); + // Should use default theme immediately without prompting, save=true to update Yjs + expect(mockSelectTheme).toHaveBeenCalledWith('base', true); }); it('should allow theme import when userStyles is enabled', async () => { @@ -2844,4 +2860,721 @@ describe('YjsProjectBridge', () => { expect(mockButton.addEventListener).toHaveBeenCalled(); }); }); + + describe('User Theme Methods', () => { + let bridge; + + beforeEach(async () => { + bridge = new YjsProjectBridge(mockApp); + bridge.documentManager = new MockYjsDocumentManager('test-project', {}); + bridge.resourceCache = { + setUserTheme: mock(() => Promise.resolve()), + hasUserTheme: mock(() => Promise.resolve(false)), + getUserTheme: mock(() => Promise.resolve(null)), + getUserThemeRaw: mock(() => Promise.resolve(null)), + }; + bridge.resourceFetcher = { + setUserThemeFiles: mock(() => Promise.resolve()), + hasUserTheme: mock(() => false), + }; + + // Mock fflate + global.window.fflate = { + zipSync: mock(() => new Uint8Array([80, 75, 3, 4])), + unzipSync: mock(() => ({ + 'config.xml': new TextEncoder().encode('Test'), + 'style.css': new Uint8Array([1, 2, 3]), + })), + }; + + // Store mock zip for _extractThemeFilesFromZip (correct property name) + bridge._pendingThemeZip = { + 'theme/config.xml': new Uint8Array(new TextEncoder().encode('Test')), + 'theme/style.css': new Uint8Array([1, 2, 3]), + }; + }); + + describe('_uint8ArrayToBase64', () => { + it('converts Uint8Array to base64 string', () => { + const input = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + const result = bridge._uint8ArrayToBase64(input); + expect(result).toBe('SGVsbG8='); + }); + + it('handles empty array', () => { + const input = new Uint8Array([]); + const result = bridge._uint8ArrayToBase64(input); + expect(result).toBe(''); + }); + }); + + describe('_base64ToUint8Array', () => { + it('converts base64 string to Uint8Array', () => { + const input = 'SGVsbG8='; // "Hello" + const result = bridge._base64ToUint8Array(input); + expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111])); + }); + + it('handles empty string', () => { + const input = ''; + const result = bridge._base64ToUint8Array(input); + expect(result).toEqual(new Uint8Array([])); + }); + }); + + describe('_extractThemeFilesFromZip', () => { + it('extracts theme files from pending ZIP', () => { + const result = bridge._extractThemeFilesFromZip(); + + expect(result).not.toBeNull(); + expect(result.files).toBeDefined(); + expect(Object.keys(result.files)).toContain('config.xml'); + expect(Object.keys(result.files)).toContain('style.css'); + }); + + it('returns null when no pending ZIP', () => { + bridge._pendingThemeZip = null; + const result = bridge._extractThemeFilesFromZip(); + expect(result).toBeNull(); + }); + + it('returns null when no theme folder in ZIP', () => { + bridge._pendingThemeZip = { + 'content.xml': new Uint8Array([1]), + }; + const result = bridge._extractThemeFilesFromZip(); + expect(result).toBeNull(); + }); + }); + + describe('_parseThemeConfigFromFiles', () => { + it('parses config.xml and creates theme configuration', () => { + const themeFilesData = { + files: { + 'config.xml': new Uint8Array([1]), + 'style.css': new Uint8Array([1]), + }, + configXml: 'My Theme1.0', + }; + + const result = bridge._parseThemeConfigFromFiles('my-theme', themeFilesData); + + expect(result).not.toBeNull(); + expect(result.name).toBe('My Theme'); + expect(result.type).toBe('user'); + expect(result.isUserTheme).toBe(true); + }); + + it('uses default values when config.xml is missing', () => { + const themeFilesData = { + files: { + 'style.css': new Uint8Array([1]), + }, + configXml: null, + }; + + const result = bridge._parseThemeConfigFromFiles('my-theme', themeFilesData); + + // Should use themeName as default values + expect(result.name).toBe('my-theme'); + expect(result.displayName).toBe('my-theme'); + expect(result.type).toBe('user'); + }); + + it('detects CSS and JS files', () => { + const themeFilesData = { + files: { + 'config.xml': new Uint8Array([1]), + 'main.css': new Uint8Array([1]), + 'extra.css': new Uint8Array([2]), + 'script.js': new Uint8Array([3]), + }, + configXml: 'Test', + }; + + const result = bridge._parseThemeConfigFromFiles('test-theme', themeFilesData); + + expect(result.cssFiles).toContain('main.css'); + expect(result.cssFiles).toContain('extra.css'); + expect(result.js).toContain('script.js'); + }); + }); + + describe('_compressThemeFiles', () => { + it('compresses files using fflate zipSync', () => { + const files = { + 'style.css': new Uint8Array([1, 2, 3]), + 'config.xml': new Uint8Array([4, 5, 6]), + }; + + const result = bridge._compressThemeFiles(files); + + expect(global.window.fflate.zipSync).toHaveBeenCalled(); + expect(result).toBeInstanceOf(Uint8Array); + }); + + it('throws when fflate not available', () => { + delete global.window.fflate; + + expect(() => { + bridge._compressThemeFiles({ 'style.css': new Uint8Array([1]) }); + }).toThrow('fflate library not loaded'); + }); + }); + + describe('_copyThemeToYjs', () => { + it('copies compressed theme to Yjs themeFiles map', async () => { + const mockThemeFilesMap = { + set: mock(() => {}), + }; + bridge.documentManager.getThemeFiles = mock(() => mockThemeFilesMap); + + await bridge._copyThemeToYjs('test-theme', { 'style.css': new Uint8Array([1]) }); + + expect(mockThemeFilesMap.set).toHaveBeenCalledWith( + 'test-theme', + expect.any(String) // base64 compressed + ); + }); + }); + + describe('_loadUserThemeFromIndexedDB', () => { + it('calls resourceCache.getUserTheme with theme name', async () => { + const mockThemeData = { + files: new Map([['style.css', new Blob(['css'])]]), + config: { id: 'test-theme', name: 'test-theme', type: 'user', isUserTheme: true }, + }; + bridge.resourceCache.getUserTheme = mock(() => Promise.resolve(mockThemeData)); + global.eXeLearning.app.themes.list.addUserTheme = mock(() => {}); + global.eXeLearning.app.themes.list.installed = {}; + + await bridge._loadUserThemeFromIndexedDB('test-theme'); + + expect(bridge.resourceCache.getUserTheme).toHaveBeenCalledWith('test-theme'); + }); + }); + + describe('loadUserThemesFromYjs', () => { + it('loads themes from Yjs themeFiles map', async () => { + const mockThemeFilesMap = { + entries: mock(() => [ + ['theme1', 'base64data1'], + ['theme2', 'base64data2'], + ]), + }; + bridge.documentManager.getThemeFiles = mock(() => mockThemeFilesMap); + bridge._loadUserThemeFromYjs = mock(() => Promise.resolve()); + + await bridge.loadUserThemesFromYjs(); + + expect(bridge._loadUserThemeFromYjs).toHaveBeenCalledTimes(2); + }); + + it('handles empty themeFiles map', async () => { + const mockThemeFilesMap = { + entries: mock(() => []), + }; + bridge.documentManager.getThemeFiles = mock(() => mockThemeFilesMap); + + // Should not throw + await expect(bridge.loadUserThemesFromYjs()).resolves.not.toThrow(); + }); + + it('handles missing documentManager', async () => { + bridge.documentManager = null; + + // Should not throw + await expect(bridge.loadUserThemesFromYjs()).resolves.not.toThrow(); + }); + }); + + describe('_decompressThemeFromYjs', () => { + it('decompresses base64 theme data', () => { + const result = bridge._decompressThemeFromYjs('UEsDBBQ='); // Minimal base64 + + expect(global.window.fflate.unzipSync).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + }); + + describe('setupThemeFilesObserver', () => { + it('sets up observer on themeFiles map', () => { + const mockObserve = mock(() => {}); + const mockThemeFilesMap = { + observe: mockObserve, + }; + bridge.documentManager.getThemeFiles = mock(() => mockThemeFilesMap); + + bridge.setupThemeFilesObserver(); + + expect(mockThemeFilesMap.observe).toHaveBeenCalled(); + }); + + it('handles missing documentManager', () => { + bridge.documentManager = null; + + // Should not throw + expect(() => bridge.setupThemeFilesObserver()).not.toThrow(); + }); + + it('handles missing getThemeFiles method', () => { + bridge.documentManager.getThemeFiles = undefined; + + // Should not throw + expect(() => bridge.setupThemeFilesObserver()).not.toThrow(); + }); + + it('handles observer callback for added themes', async () => { + let observerCallback = null; + const mockThemeFilesMap = { + observe: (cb) => { + observerCallback = cb; + }, + get: mock(() => 'base64themedata'), + }; + bridge.documentManager.getThemeFiles = mock(() => mockThemeFilesMap); + bridge._loadUserThemeFromYjs = mock(() => Promise.resolve()); + + bridge.setupThemeFilesObserver(); + + // Simulate observer callback for 'add' action + await observerCallback({ + changes: { + keys: [['new-theme', { action: 'add' }]], + }, + }); + + expect(bridge._loadUserThemeFromYjs).toHaveBeenCalledWith('new-theme', 'base64themedata'); + }); + + it('handles observer callback for deleted themes', async () => { + let observerCallback = null; + const mockThemeFilesMap = { + observe: (cb) => { + observerCallback = cb; + }, + get: mock(() => null), + }; + bridge.documentManager.getThemeFiles = mock(() => mockThemeFilesMap); + bridge.resourceFetcher = { + userThemeFiles: new Map([['deleted-theme', {}]]), + cache: new Map([['theme:deleted-theme', {}]]), + }; + + bridge.setupThemeFilesObserver(); + + // Simulate observer callback for 'delete' action + await observerCallback({ + changes: { + keys: [['deleted-theme', { action: 'delete' }]], + }, + }); + + expect(bridge.resourceFetcher.userThemeFiles.has('deleted-theme')).toBe(false); + expect(bridge.resourceFetcher.cache.has('theme:deleted-theme')).toBe(false); + }); + }); + + describe('_loadUserThemeFromYjs - extended', () => { + it('returns early if theme already loaded in ResourceFetcher', async () => { + bridge.resourceFetcher.hasUserTheme = mock(() => true); + bridge._decompressThemeFromYjs = mock(() => {}); + + await bridge._loadUserThemeFromYjs('existing-theme', 'somedata'); + + expect(bridge._decompressThemeFromYjs).not.toHaveBeenCalled(); + }); + + it('loads from IndexedDB when available', async () => { + bridge.resourceCache.hasUserTheme = mock(() => Promise.resolve(true)); + bridge._loadUserThemeFromIndexedDB = mock(() => Promise.resolve()); + + await bridge._loadUserThemeFromYjs('idb-theme', 'somedata'); + + expect(bridge._loadUserThemeFromIndexedDB).toHaveBeenCalledWith('idb-theme'); + }); + + it('handles IndexedDB check error gracefully', async () => { + bridge.resourceCache.hasUserTheme = mock(() => Promise.reject(new Error('IDB error'))); + bridge._decompressThemeFromYjs = mock(() => ({ files: {}, configXml: null })); + + // Should not throw + await expect(bridge._loadUserThemeFromYjs('theme', 'data')).resolves.not.toThrow(); + }); + + it('handles legacy Y.Map format', async () => { + const legacyMap = { + entries: mock(() => [ + ['config.xml', 'PGNvbmZpZz48L2NvbmZpZz4='], // + ['style.css', 'Ym9keXt9'], // body{} + ]), + }; + bridge.resourceCache.hasUserTheme = mock(() => Promise.resolve(false)); + bridge.resourceFetcher.hasUserTheme = mock(() => false); + bridge._parseThemeConfigFromFiles = mock(() => ({ name: 'legacy' })); + + await bridge._loadUserThemeFromYjs('legacy-theme', legacyMap); + + expect(bridge._parseThemeConfigFromFiles).toHaveBeenCalled(); + }); + + it('skips unknown theme data format', async () => { + bridge.resourceCache.hasUserTheme = mock(() => Promise.resolve(false)); + bridge.resourceFetcher.hasUserTheme = mock(() => false); + bridge._parseThemeConfigFromFiles = mock(() => ({})); + + // Pass an object that is not a string and has no entries() function + await bridge._loadUserThemeFromYjs('unknown-theme', { someKey: 'someValue' }); + + expect(bridge._parseThemeConfigFromFiles).not.toHaveBeenCalled(); + }); + + it('skips theme with no files', async () => { + bridge.resourceCache.hasUserTheme = mock(() => Promise.resolve(false)); + bridge.resourceFetcher.hasUserTheme = mock(() => false); + bridge._decompressThemeFromYjs = mock(() => ({ files: {}, configXml: null })); + bridge._parseThemeConfigFromFiles = mock(() => ({})); + + await bridge._loadUserThemeFromYjs('empty-theme', 'somedata'); + + expect(bridge._parseThemeConfigFromFiles).not.toHaveBeenCalled(); + }); + + it('skips theme when config parsing fails', async () => { + bridge.resourceCache.hasUserTheme = mock(() => Promise.resolve(false)); + bridge.resourceFetcher.hasUserTheme = mock(() => false); + bridge._decompressThemeFromYjs = mock(() => ({ + files: { 'style.css': new Uint8Array([1]) }, + configXml: null, + })); + bridge._parseThemeConfigFromFiles = mock(() => null); + + await bridge._loadUserThemeFromYjs('bad-config-theme', 'somedata'); + + expect(bridge.resourceCache.setUserTheme).not.toHaveBeenCalled(); + }); + + it('saves to IndexedDB and registers with ResourceFetcher', async () => { + bridge.resourceCache.hasUserTheme = mock(() => Promise.resolve(false)); + bridge.resourceFetcher.hasUserTheme = mock(() => false); + bridge._decompressThemeFromYjs = mock(() => ({ + files: { 'style.css': new Uint8Array([1]) }, + configXml: 'Test', + })); + bridge._compressThemeFiles = mock(() => new Uint8Array([1, 2, 3])); + bridge._parseThemeConfigFromFiles = mock(() => ({ + name: 'good-theme', + type: 'user', + isUserTheme: true, + })); + global.eXeLearning.app.themes.list.installed = {}; + global.eXeLearning.app.themes.list.addUserTheme = mock(() => {}); + + await bridge._loadUserThemeFromYjs('good-theme', 'somedata'); + + expect(bridge.resourceCache.setUserTheme).toHaveBeenCalled(); + expect(bridge.resourceFetcher.setUserThemeFiles).toHaveBeenCalled(); + expect(global.eXeLearning.app.themes.list.addUserTheme).toHaveBeenCalled(); + }); + + it('handles error saving to IndexedDB', async () => { + bridge.resourceCache.hasUserTheme = mock(() => Promise.resolve(false)); + bridge.resourceCache.setUserTheme = mock(() => Promise.reject(new Error('IDB save error'))); + bridge.resourceFetcher.hasUserTheme = mock(() => false); + bridge._decompressThemeFromYjs = mock(() => ({ + files: { 'style.css': new Uint8Array([1]) }, + configXml: 'Test', + })); + bridge._compressThemeFiles = mock(() => new Uint8Array([1, 2, 3])); + bridge._parseThemeConfigFromFiles = mock(() => ({ + name: 'test-theme', + type: 'user', + })); + + // Should not throw + await expect(bridge._loadUserThemeFromYjs('test-theme', 'data')).resolves.not.toThrow(); + }); + + it('skips adding to installed themes if already exists', async () => { + bridge.resourceCache.hasUserTheme = mock(() => Promise.resolve(false)); + bridge.resourceFetcher.hasUserTheme = mock(() => false); + bridge._decompressThemeFromYjs = mock(() => ({ + files: { 'style.css': new Uint8Array([1]) }, + configXml: 'Test', + })); + bridge._compressThemeFiles = mock(() => new Uint8Array([1, 2, 3])); + bridge._parseThemeConfigFromFiles = mock(() => ({ + name: 'existing-theme', + type: 'user', + })); + global.eXeLearning.app.themes.list.installed = { 'existing-theme': {} }; + global.eXeLearning.app.themes.list.addUserTheme = mock(() => {}); + + await bridge._loadUserThemeFromYjs('existing-theme', 'data'); + + expect(global.eXeLearning.app.themes.list.addUserTheme).not.toHaveBeenCalled(); + }); + + it('handles top-level error', async () => { + bridge.resourceCache = null; + bridge.resourceFetcher = null; + + // Should not throw even with null dependencies + await expect(bridge._loadUserThemeFromYjs('theme', 'data')).resolves.not.toThrow(); + }); + }); + }); + + describe('disconnect', () => { + let bridge; + + beforeEach(async () => { + bridge = new YjsProjectBridge(mockApp); + await bridge.initialize(123, 'test-token'); + }); + + it('cleans up all resources', async () => { + const mockDocumentManagerDestroy = mock(() => Promise.resolve()); + const mockAssetWSHandlerDestroy = mock(() => {}); + const mockAssetManagerCleanup = mock(() => {}); + const mockAssetCacheDestroy = mock(() => {}); + const mockConnectionMonitorDestroy = mock(() => {}); + + bridge.documentManager = { destroy: mockDocumentManagerDestroy }; + bridge.assetWebSocketHandler = { destroy: mockAssetWSHandlerDestroy }; + bridge.assetManager = { cleanup: mockAssetManagerCleanup }; + bridge.assetCache = { destroy: mockAssetCacheDestroy }; + bridge.saveManager = { save: () => {} }; + bridge.connectionMonitor = { destroy: mockConnectionMonitorDestroy }; + + await bridge.disconnect(); + + expect(mockDocumentManagerDestroy).toHaveBeenCalled(); + expect(mockAssetWSHandlerDestroy).toHaveBeenCalled(); + expect(mockAssetManagerCleanup).toHaveBeenCalled(); + expect(mockAssetCacheDestroy).toHaveBeenCalled(); + expect(mockConnectionMonitorDestroy).toHaveBeenCalled(); + expect(bridge.initialized).toBe(false); + expect(bridge.saveManager).toBeNull(); + expect(bridge.connectionMonitor).toBeNull(); + }); + + it('handles disconnect without assetCache.destroy method', async () => { + bridge.documentManager = { destroy: mock(() => Promise.resolve()) }; + bridge.assetCache = {}; // No destroy method + bridge.assetWebSocketHandler = null; + bridge.assetManager = null; + bridge.connectionMonitor = null; + + await expect(bridge.disconnect()).resolves.not.toThrow(); + }); + + it('handles disconnect with null resources', async () => { + bridge.documentManager = null; + bridge.assetWebSocketHandler = null; + bridge.assetManager = null; + bridge.assetCache = null; + bridge.connectionMonitor = null; + + await expect(bridge.disconnect()).resolves.not.toThrow(); + expect(bridge.initialized).toBe(false); + }); + }); + + describe('importStructure', () => { + let bridge; + + beforeEach(async () => { + bridge = new YjsProjectBridge(mockApp); + await bridge.initialize(123, 'test-token'); + }); + + it('imports API structure via structureBinding', () => { + const mockImportFromApi = mock(() => {}); + bridge.structureBinding = { + importFromApiStructure: mockImportFromApi, + }; + bridge.updateUndoRedoButtons = mock(() => {}); + + const apiStructure = [{ id: 'page-1', pageName: 'Page 1' }]; + bridge.importStructure(apiStructure); + + expect(mockImportFromApi).toHaveBeenCalledWith(apiStructure); + expect(bridge.updateUndoRedoButtons).toHaveBeenCalled(); + }); + + it('handles missing structureBinding', () => { + bridge.structureBinding = null; + + // Should not throw + expect(() => bridge.importStructure([])).not.toThrow(); + }); + }); + + describe('clearNavigation', () => { + let bridge; + + beforeEach(async () => { + bridge = new YjsProjectBridge(mockApp); + await bridge.initialize(123, 'test-token'); + }); + + it('clears navigation via structureBinding', () => { + const mockClearNav = mock(() => {}); + bridge.structureBinding = { + clearNavigation: mockClearNav, + }; + + bridge.clearNavigation(); + + expect(mockClearNav).toHaveBeenCalled(); + }); + + it('handles missing structureBinding', () => { + bridge.structureBinding = null; + + // Should not throw + expect(() => bridge.clearNavigation()).not.toThrow(); + }); + }); + + describe('onStructureChange', () => { + let bridge; + + beforeEach(async () => { + bridge = new YjsProjectBridge(mockApp); + await bridge.initialize(123, 'test-token'); + }); + + it('registers callback and returns unsubscribe function', () => { + const callback = () => {}; + + const unsubscribe = bridge.onStructureChange(callback); + + expect(bridge.structureObservers).toContain(callback); + + unsubscribe(); + + expect(bridge.structureObservers).not.toContain(callback); + }); + }); + + describe('onSaveStatus', () => { + let bridge; + + beforeEach(async () => { + bridge = new YjsProjectBridge(mockApp); + await bridge.initialize(123, 'test-token'); + }); + + it('registers callback and returns unsubscribe function', () => { + const callback = () => {}; + + const unsubscribe = bridge.onSaveStatus(callback); + + expect(bridge.saveStatusCallbacks).toContain(callback); + + unsubscribe(); + + expect(bridge.saveStatusCallbacks).not.toContain(callback); + }); + }); + + describe('getAssetManager', () => { + let bridge; + + beforeEach(async () => { + bridge = new YjsProjectBridge(mockApp); + await bridge.initialize(123, 'test-token'); + }); + + it('returns assetManager instance', () => { + bridge.assetManager = { id: 'test-asset-manager' }; + + expect(bridge.getAssetManager()).toBe(bridge.assetManager); + }); + + it('returns null when not set', () => { + bridge.assetManager = null; + + expect(bridge.getAssetManager()).toBeNull(); + }); + }); + + describe('getAssetWebSocketHandler', () => { + let bridge; + + beforeEach(async () => { + bridge = new YjsProjectBridge(mockApp); + await bridge.initialize(123, 'test-token'); + }); + + it('returns assetWebSocketHandler instance', () => { + bridge.assetWebSocketHandler = { id: 'test-ws-handler' }; + + expect(bridge.getAssetWebSocketHandler()).toBe(bridge.assetWebSocketHandler); + }); + + it('returns null when not set', () => { + bridge.assetWebSocketHandler = null; + + expect(bridge.getAssetWebSocketHandler()).toBeNull(); + }); + }); + + describe('requestMissingAssets', () => { + let bridge; + + beforeEach(async () => { + bridge = new YjsProjectBridge(mockApp); + await bridge.initialize(123, 'test-token'); + }); + + it('delegates to assetWebSocketHandler', async () => { + const mockRequest = mock(() => Promise.resolve(['asset-1', 'asset-2'])); + bridge.assetWebSocketHandler = { + requestMissingAssetsFromHTML: mockRequest, + }; + + const result = await bridge.requestMissingAssets(''); + + expect(mockRequest).toHaveBeenCalledWith(''); + expect(result).toEqual(['asset-1', 'asset-2']); + }); + + it('returns empty array when handler not available', async () => { + bridge.assetWebSocketHandler = null; + + const result = await bridge.requestMissingAssets(''); + + expect(result).toEqual([]); + }); + }); + + describe('announceAssets', () => { + let bridge; + + beforeEach(async () => { + bridge = new YjsProjectBridge(mockApp); + await bridge.initialize(123, 'test-token'); + }); + + it('calls announceAssetAvailability on handler', async () => { + const mockAnnounce = mock(() => Promise.resolve()); + bridge.assetWebSocketHandler = { + announceAssetAvailability: mockAnnounce, + }; + + await bridge.announceAssets(); + + expect(mockAnnounce).toHaveBeenCalled(); + }); + + it('handles missing handler gracefully', async () => { + bridge.assetWebSocketHandler = null; + + // Should not throw + await expect(bridge.announceAssets()).resolves.not.toThrow(); + }); + }); }); diff --git a/public/app/yjs/YjsProviderFactory.js b/public/app/yjs/YjsProviderFactory.js new file mode 100644 index 000000000..d748e9618 --- /dev/null +++ b/public/app/yjs/YjsProviderFactory.js @@ -0,0 +1,155 @@ +/** + * YjsProviderFactory - Creates Yjs providers based on capabilities. + * + * This factory abstracts the decision of which Yjs providers to use: + * - IndexedDB persistence: Always used for local offline storage + * - WebSocket provider: Only used when collaboration is enabled + * + * Usage: + * ```javascript + * const factory = new YjsProviderFactory(capabilities, config); + * const { indexedDB, websocket, awareness } = await factory.createProviders(ydoc, projectId); + * ``` + */ + +export class YjsProviderFactory { + /** + * @param {import('../core/Capabilities').Capabilities} capabilities + * @param {Object} [config] + * @param {string} [config.wsUrl] - WebSocket URL for collaboration + * @param {string} [config.token] - Auth token for WebSocket + * @param {string} [config.dbPrefix] - IndexedDB database name prefix + */ + constructor(capabilities, config = {}) { + this.capabilities = capabilities; + this.config = { + wsUrl: config.wsUrl || null, + token: config.token || '', + dbPrefix: config.dbPrefix || 'exelearning-project-', + }; + } + + /** + * Create Yjs providers for a document. + * @param {Y.Doc} ydoc - Yjs document + * @param {string} projectId - Project UUID + * @returns {Promise<{indexedDB: Object|null, websocket: Object|null, awareness: Object|null}>} + */ + async createProviders(ydoc, projectId) { + const result = { + indexedDB: null, + websocket: null, + awareness: null, + }; + + // IndexedDB persistence - ALWAYS created for local storage + result.indexedDB = await this._createIndexedDBProvider(ydoc, projectId); + + // WebSocket provider - ONLY if collaboration is enabled + if (this.capabilities.collaboration.enabled && this.config.wsUrl) { + const wsResult = await this._createWebSocketProvider(ydoc, projectId); + result.websocket = wsResult.provider; + result.awareness = wsResult.awareness; + } + + return result; + } + + /** + * Create IndexedDB persistence provider. + * @private + */ + async _createIndexedDBProvider(ydoc, projectId) { + const IndexeddbPersistence = window.IndexeddbPersistence; + if (!IndexeddbPersistence) { + console.warn( + '[YjsProviderFactory] IndexeddbPersistence not loaded. ' + + 'Ensure y-indexeddb.min.js is loaded first.' + ); + return null; + } + + const dbName = `${this.config.dbPrefix}${projectId}`; + const provider = new IndexeddbPersistence(dbName, ydoc); + + // Wait for sync + return new Promise((resolve) => { + provider.on('synced', () => { + resolve(provider); + }); + + // Timeout fallback + setTimeout(() => { + if (!provider.synced) { + console.warn('[YjsProviderFactory] IndexedDB sync timeout, continuing...'); + resolve(provider); + } + }, 5000); + }); + } + + /** + * Create WebSocket provider for real-time collaboration. + * @private + */ + async _createWebSocketProvider(ydoc, projectId) { + const WebsocketProvider = window.WebsocketProvider; + if (!WebsocketProvider) { + console.warn( + '[YjsProviderFactory] WebsocketProvider not loaded. ' + + 'Collaboration features will be disabled.' + ); + return { provider: null, awareness: null }; + } + + const roomName = `project-${projectId}`; + + const provider = new WebsocketProvider(this.config.wsUrl, roomName, ydoc, { + // Don't auto-connect - let caller control when to connect + connect: false, + // Pass JWT token as URL param for authentication + params: { token: this.config.token }, + }); + + return { + provider, + awareness: provider.awareness, + }; + } + + /** + * Check if WebSocket provider is available. + * @returns {boolean} + */ + isWebSocketAvailable() { + return ( + this.capabilities.collaboration.enabled && + !!this.config.wsUrl && + !!window.WebsocketProvider + ); + } + + /** + * Check if IndexedDB provider is available. + * @returns {boolean} + */ + isIndexedDBAvailable() { + return !!window.IndexeddbPersistence; + } + + /** + * Generate a random user color for awareness. + * @returns {string} - Hex color string + */ + generateUserColor() { + const colors = [ + '#f44336', '#e91e63', '#9c27b0', '#673ab7', + '#3f51b5', '#2196f3', '#03a9f4', '#00bcd4', + '#009688', '#4caf50', '#8bc34a', '#cddc39', + '#ffeb3b', '#ffc107', '#ff9800', '#ff5722', + ]; + return colors[Math.floor(Math.random() * colors.length)]; + } +} + +export default YjsProviderFactory; diff --git a/public/app/yjs/yjs-loader.js b/public/app/yjs/yjs-loader.js index f2e9f1635..2cc36543b 100644 --- a/public/app/yjs/yjs-loader.js +++ b/public/app/yjs/yjs-loader.js @@ -34,8 +34,17 @@ // Get basePath and version from eXeLearning (set by pages.controller.ts) const getBasePath = () => window.eXeLearning?.config?.basePath || ''; const getVersion = () => window.eXeLearning?.version || 'v1.0.0'; + // Note: Direct __EXE_STATIC_MODE__ check required here because this runs very early, + // before App is initialized and capabilities are available + const isStaticMode = () => window.__EXE_STATIC_MODE__ === true; // URL pattern: {basePath}/{version}/path (e.g., /web/exelearning/v0.0.0-alpha/libs/yjs/yjs.min.js) - const assetPath = (path) => `${getBasePath()}/${getVersion()}${path.startsWith('/') ? path : '/' + path}`; + // In static mode, use relative paths without version prefix + const assetPath = (path) => { + if (isStaticMode()) { + return `.${path.startsWith('/') ? path : '/' + path}`; + } + return `${getBasePath()}/${getVersion()}${path.startsWith('/') ? path : '/' + path}`; + }; // Paths are computed lazily to ensure eXeLearning globals are available const getLIBS_PATH = () => assetPath('/libs/yjs'); diff --git a/public/files/perm/idevices/base/checklist/export/checklist.js b/public/files/perm/idevices/base/checklist/export/checklist.js index de8779567..291185532 100644 --- a/public/files/perm/idevices/base/checklist/export/checklist.js +++ b/public/files/perm/idevices/base/checklist/export/checklist.js @@ -98,6 +98,12 @@ var $eXeListaCotejo = { const mOptions = $exeDevices.iDevice.gamification.helpers.isJsonString(json); + // Guard against invalid JSON data + if (!mOptions || typeof mOptions !== 'object') { + console.warn('[checklist] Invalid JSON data, returning empty options'); + return { levels: [], urlCommunity: imglink, urlLogo: imglink1, urlDecorative: imglink2 }; + } + mOptions.urlCommunity = imglink; mOptions.urlLogo = imglink1; mOptions.urlDecorative = imglink2; @@ -109,6 +115,9 @@ var $eXeListaCotejo = { mOptions.points = 0; mOptions.totalPoints = 0; + // Ensure levels array exists (legacy compatibility) + mOptions.levels = mOptions.levels || []; + for (let i = 0; i < mOptions.levels.length; i++) { mOptions.levels[i].points = typeof mOptions.levels[i].points == 'undefined' diff --git a/public/files/perm/themes/base/neo/style.css b/public/files/perm/themes/base/neo/style.css index 3dae97433..5182877f1 100644 --- a/public/files/perm/themes/base/neo/style.css +++ b/public/files/perm/themes/base/neo/style.css @@ -1,16 +1,16 @@ @charset "UTF-8"; @font-face { font-family: "Nunito"; - src: url(fonts/Nunito-ExtraLightItalic.woff2) format("woff2"), - url(fonts/Nunito-ExtraLightItalic.woff) format("woff"); + src: url(fonts/Nunito-Italic.woff2) format("woff2"), + url(fonts/Nunito-Italic.woff) format("woff"); font-style: italic; font-display: swap; } @font-face { font-family: "Nunito"; - src: url(fonts/Nunito-ExtraLight.woff2) format("woff2"), - url(fonts/Nunito-ExtraLight.woff) format("woff"); + src: url(fonts/Nunito-Regular.woff2) format("woff2"), + url(fonts/Nunito-Regular.woff) format("woff"); font-style: normal; font-display: swap; } diff --git a/public/libs/README b/public/libs/README.md similarity index 100% rename from public/libs/README rename to public/libs/README.md diff --git a/public/libs/abcjs/exe_abc_music.js b/public/libs/abcjs/exe_abc_music.js index c8c676118..0586e76e1 100644 --- a/public/libs/abcjs/exe_abc_music.js +++ b/public/libs/abcjs/exe_abc_music.js @@ -226,10 +226,18 @@ $exeABCmusic = { appendFilesAbcNotation() { if (window.eXeLearning === undefined) return; // Not load the scripts dynamically in the export - // Use versioned path for cache busting: {basePath}/{version}/libs/... - const basePath = eXeLearning.config.basePath || ''; - const version = eXeLearning.version || 'v1.0.0'; - let libsPath = `${basePath}/${version}/libs`; + // Determine libs path based on mode + let libsPath; + if (window.__EXE_STATIC_MODE__) { + // Static mode: use relative paths without version prefix + const basePath = eXeLearning.config.basePath || '.'; + libsPath = `${basePath}/libs`; + } else { + // Server mode: use versioned paths for cache busting + const basePath = eXeLearning.config.basePath || ''; + const version = eXeLearning.version || 'v1.0.0'; + libsPath = `${basePath}/${version}/libs`; + } let abcmusicPath = `${libsPath}/tinymce_5/js/tinymce/plugins/abcmusic`; let head = document.querySelector("head"); diff --git a/public/libs/tinymce_5/js/tinymce/plugins/codemagic/codemagic.html b/public/libs/tinymce_5/js/tinymce/plugins/codemagic/codemagic.html index 1da10fdfb..b49bec0b6 100644 --- a/public/libs/tinymce_5/js/tinymce/plugins/codemagic/codemagic.html +++ b/public/libs/tinymce_5/js/tinymce/plugins/codemagic/codemagic.html @@ -10,11 +10,42 @@ diff --git a/public/libs/tinymce_5/js/tinymce/plugins/codemagic/plugin.min.js b/public/libs/tinymce_5/js/tinymce/plugins/codemagic/plugin.min.js index fff72ee45..7231ddf90 100644 --- a/public/libs/tinymce_5/js/tinymce/plugins/codemagic/plugin.min.js +++ b/public/libs/tinymce_5/js/tinymce/plugins/codemagic/plugin.min.js @@ -67,14 +67,30 @@ jQuery(document).ready(function ($) { }); function open_codemagic() { - // Use API endpoint to bypass Bun's HTML bundler var basePath = (window.eXeLearning && window.eXeLearning.config && window.eXeLearning.config.basePath) || ''; + var codemagicUrl; + + // Check if we're in static mode (no server available) + // Use capabilities first, fall back to __EXE_STATIC_MODE__ for early init + var capabilities = window.eXeLearning && window.eXeLearning.app && window.eXeLearning.app.capabilities; + var isStaticMode = capabilities + ? !capabilities.storage.remote + : window.__EXE_STATIC_MODE__; + + // In static mode, use the direct file path (no API server available) + // In server mode, use API endpoint to bypass Bun's HTML bundler + if (isStaticMode) { + codemagicUrl = basePath + '/libs/tinymce_5/js/tinymce/plugins/codemagic/codemagic.html'; + } else { + codemagicUrl = basePath + '/api/codemagic-editor/codemagic.html'; + } + codemagicDialog = editor.windowManager.openUrl({ title: _('Edit source code'), width: 900, height: 650, // maximizable: true, - url: basePath + '/api/codemagic-editor/codemagic.html' + url: codemagicUrl }); } diff --git a/scripts/build-static-bundle.ts b/scripts/build-static-bundle.ts new file mode 100644 index 000000000..903b29451 --- /dev/null +++ b/scripts/build-static-bundle.ts @@ -0,0 +1,1689 @@ +#!/usr/bin/env bun +/** + * Build script for static/offline distribution + * + * Generates a self-contained static distribution that can run without a server. + * + * Output structure: + * dist/static/ + * ├── index.html # Static entry point + * ├── app/ # Bundled JavaScript + * ├── libs/ # External libraries + * ├── style/ # CSS + * ├── bundles/ # Pre-built resource ZIPs (from public/bundles/) + * ├── data/ + * │ ├── bundle.json # Pre-serialized API data + * │ └── translations/ # Per-locale JSON + * ├── manifest.json # PWA manifest + * └── service-worker.js # PWA service worker + * + * Usage: + * bun scripts/build-static-bundle.ts + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; +import { XMLParser } from 'fast-xml-parser'; + +const projectRoot = path.resolve(import.meta.dir, '..'); +const outputDir = path.join(projectRoot, 'dist/static'); + +// Read version from package.json +const packageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8')); +const buildVersion = `v${packageJson.version}`; + +// Get git commit hash for cache busting (ensures cache invalidation on each deploy) +let buildHash: string; +try { + buildHash = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim(); +} catch { + // Fallback to timestamp if git not available + buildHash = Date.now().toString(36); +} + +// Supported locales (from translations/) +const LOCALES = ['ca', 'en', 'eo', 'es', 'eu', 'gl', 'pt', 'ro', 'va']; + +// Locale display names +const LOCALE_NAMES: Record = { + ca: 'Català', + en: 'English', + eo: 'Esperanto', + es: 'Español', + eu: 'Euskara', + gl: 'Galego', + pt: 'Português', + ro: 'Română', + va: 'Valencià', +}; + +/** + * Parse XLF file to extract translations + */ +function parseXlfFile(filePath: string): Record { + const translations: Record = {}; + + if (!fs.existsSync(filePath)) { + console.warn(`Translation file not found: ${filePath}`); + return translations; + } + + const content = fs.readFileSync(filePath, 'utf-8'); + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + }); + + try { + const parsed = parser.parse(content); + const transUnits = parsed?.xliff?.file?.body?.['trans-unit']; + + if (Array.isArray(transUnits)) { + for (const unit of transUnits) { + const source = unit.source; + const target = unit.target; + if (source && target) { + translations[source] = target; + } + } + } else if (transUnits) { + // Single translation + if (transUnits.source && transUnits.target) { + translations[transUnits.source] = transUnits.target; + } + } + } catch (error) { + console.error(`Error parsing ${filePath}:`, error); + } + + return translations; +} + +/** + * Load all translations + */ +function loadAllTranslations(): Record; count: number }> { + const result: Record; count: number }> = {}; + const translationsDir = path.join(projectRoot, 'translations'); + + for (const locale of LOCALES) { + const filePath = path.join(translationsDir, `messages.${locale}.xlf`); + const translations = parseXlfFile(filePath); + result[locale] = { + translations, + count: Object.keys(translations).length, + }; + console.log(` Loaded ${Object.keys(translations).length} translations for ${locale}`); + } + + return result; +} + +interface IdeviceConfig { + name: string; + id: string; + title: string; + cssClass: string; + category: string; + icon: { name: string; url: string; type: string }; + version: string; + apiVersion: string; + componentType: string; + author: string; + authorUrl: string; + license: string; + licenseUrl: string; + description: string; + downloadable: boolean; + url: string; + editionJs: string[]; + editionCss: string[]; + exportJs: string[]; + exportCss: string[]; + editionTemplateFilename: string; + exportTemplateFilename: string; + editionTemplateContent: string; + exportTemplateContent: string; + exportObject: string; + location: string; + locationType: string; +} + +/** + * Read template file content safely + */ +function readTemplateContent(basePath: string, folder: string, filename: string): string { + if (!filename) return ''; + try { + const templatePath = path.join(basePath, folder, filename); + if (fs.existsSync(templatePath)) { + return fs.readFileSync(templatePath, 'utf-8'); + } + } catch { + // Ignore errors, return empty string + } + return ''; +} + +/** + * Parse iDevice config.xml (same logic as server) + */ +function parseIdeviceConfig(xmlContent: string, ideviceId: string, basePath: string): IdeviceConfig | null { + try { + const getValue = (tag: string): string => { + const match = xmlContent.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`)); + return match ? match[1].trim() : ''; + }; + + const getNestedValue = (parent: string, child: string): string => { + const parentMatch = xmlContent.match(new RegExp(`<${parent}>([\\s\\S]*?)<\\/${parent}>`)); + if (!parentMatch) return ''; + const childMatch = parentMatch[1].match(new RegExp(`<${child}>([\\s\\S]*?)<\\/${child}>`)); + return childMatch ? childMatch[1].trim() : ''; + }; + + // Parse list of filenames and verify they exist on disk + const getValidFilenames = (tag: string, subfolder: 'edition' | 'export'): string[] => { + const parentMatch = xmlContent.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`)); + let filenames: string[]; + + if (!parentMatch) { + const folderPath = path.join(basePath, subfolder); + const extension = tag.includes('js') ? '.js' : '.css'; + if (fs.existsSync(folderPath)) { + try { + filenames = fs.readdirSync(folderPath) + .filter(file => file.endsWith(extension) && !file.includes('.test.') && !file.includes('.spec.')) + .sort((a, b) => { + if (a === `${ideviceId}${extension}`) return -1; + if (b === `${ideviceId}${extension}`) return 1; + return a.localeCompare(b); + }); + } catch { + filenames = [`${ideviceId}${extension}`]; + } + } else { + filenames = [`${ideviceId}${extension}`]; + } + } else { + filenames = []; + const filenameMatches = parentMatch[1].matchAll(/([^<]+)<\/filename>/g); + for (const match of filenameMatches) { + filenames.push(match[1].trim()); + } + if (filenames.length === 0) { + filenames = [`${ideviceId}.${tag.includes('js') ? 'js' : 'css'}`]; + } + } + + return filenames.filter(filename => { + const filePath = path.join(basePath, subfolder, filename); + return fs.existsSync(filePath); + }); + }; + + // Handle icon + let icon = { name: `${ideviceId}-icon`, url: `${ideviceId}-icon.svg`, type: 'img' }; + const iconContent = getValue('icon'); + if (iconContent && !iconContent.includes('<')) { + icon = { name: iconContent, url: iconContent, type: 'icon' }; + } else if (iconContent) { + icon = { + name: getNestedValue('icon', 'name') || `${ideviceId}-icon`, + url: getNestedValue('icon', 'url') || `${ideviceId}-icon.svg`, + type: getNestedValue('icon', 'type') || 'img', + }; + } + + // Get template filenames + const editionTemplateFilename = getValue('edition-template-filename') || ''; + const exportTemplateFilename = getValue('export-template-filename') || ''; + + // Read template content from files + const editionTemplateContent = readTemplateContent(basePath, 'edition', editionTemplateFilename); + const exportTemplateContent = readTemplateContent(basePath, 'export', exportTemplateFilename); + + // exportObject is the global JS object name used for rendering (e.g., '$text') + // Can be specified in config.xml or defaults to '$' + ideviceId (without dashes) + const exportObject = getValue('export-object') || `$${ideviceId.split('-').join('')}`; + + return { + name: ideviceId, + id: ideviceId, + title: getValue('title') || ideviceId, + cssClass: getValue('css-class') || ideviceId, + category: getValue('category') || 'Uncategorized', + icon, + version: getValue('version') || '1.0', + apiVersion: getValue('api-version') || '3.0', + componentType: getValue('component-type') || 'html', + author: getValue('author') || '', + authorUrl: getValue('author-url') || '', + license: getValue('license') || '', + licenseUrl: getValue('license-url') || '', + description: getValue('description') || '', + downloadable: getValue('downloadable') === '1', + url: `/files/perm/idevices/base/${ideviceId}`, + editionJs: getValidFilenames('edition-js', 'edition'), + editionCss: getValidFilenames('edition-css', 'edition'), + exportJs: getValidFilenames('export-js', 'export'), + exportCss: getValidFilenames('export-css', 'export'), + editionTemplateFilename, + exportTemplateFilename, + editionTemplateContent, + exportTemplateContent, + exportObject, + location: getValue('location') || '', + locationType: getValue('location-type') || '', + }; + } catch { + return null; + } +} + +/** + * Build iDevices list from directory structure with full config data + */ +function buildIdevicesList(): { idevices: IdeviceConfig[] } { + const idevicesDir = path.join(projectRoot, 'public/files/perm/idevices/base'); + const idevices: IdeviceConfig[] = []; + + if (!fs.existsSync(idevicesDir)) { + console.warn('iDevices directory not found:', idevicesDir); + return { idevices }; + } + + const dirs = fs.readdirSync(idevicesDir, { withFileTypes: true }); + for (const dir of dirs) { + if (dir.isDirectory() && !dir.name.startsWith('.')) { + const configPath = path.join(idevicesDir, dir.name, 'config.xml'); + if (fs.existsSync(configPath)) { + const xmlContent = fs.readFileSync(configPath, 'utf-8'); + const config = parseIdeviceConfig(xmlContent, dir.name, path.join(idevicesDir, dir.name)); + if (config) { + idevices.push(config); + } + } + } + } + + // Sort by category then title + idevices.sort((a, b) => { + if (a.category !== b.category) return a.category.localeCompare(b.category); + return a.title.localeCompare(b.title); + }); + + console.log(` Found ${idevices.length} iDevices`); + return { idevices }; +} + +/** + * Theme icon interface + */ +interface ThemeIcon { + id: string; + title: string; + type: 'img'; + value: string; // URL path to the icon image +} + +/** + * Theme interface matching what navbarStyles.js expects + */ +interface Theme { + id: string; + name: string; + dirName: string; + title: string; + type: 'base' | 'site' | 'admin' | 'user'; + url: string; // Used by Theme class to build path + description: string; + valid: boolean; + downloadable: string; + cssFiles: string[]; // CSS files to load for the theme + icons: Record; // Theme icons for block icon picker +} + +/** + * Scan theme directory for icon files + */ +function scanThemeIcons(themePath: string, themeUrl: string): Record { + const iconsPath = path.join(themePath, 'icons'); + if (!fs.existsSync(iconsPath)) return {}; + + const icons: Record = {}; + const entries = fs.readdirSync(iconsPath, { withFileTypes: true }); + + for (const entry of entries) { + if ( + entry.isFile() && + (entry.name.endsWith('.png') || + entry.name.endsWith('.svg') || + entry.name.endsWith('.gif') || + entry.name.endsWith('.jpg') || + entry.name.endsWith('.jpeg')) + ) { + const iconId = path.basename(entry.name, path.extname(entry.name)); + icons[iconId] = { + id: iconId, + title: iconId, + type: 'img', + value: `${themeUrl}/icons/${entry.name}`, + }; + } + } + return icons; +} + +/** + * Build themes list from directory structure + */ +function buildThemesList(): { themes: Theme[] } { + const themesDir = path.join(projectRoot, 'public/files/perm/themes/base'); + const themes: Theme[] = []; + + if (!fs.existsSync(themesDir)) { + console.warn('Themes directory not found:', themesDir); + return { themes }; + } + + const dirs = fs.readdirSync(themesDir, { withFileTypes: true }); + for (const dir of dirs) { + if (dir.isDirectory() && !dir.name.startsWith('.')) { + // Check for config.xml or config.json + const configXmlPath = path.join(themesDir, dir.name, 'config.xml'); + const configJsonPath = path.join(themesDir, dir.name, 'config.json'); + const hasConfig = fs.existsSync(configXmlPath) || fs.existsSync(configJsonPath); + + // Parse description from config.xml if available + let description = ''; + if (fs.existsSync(configXmlPath)) { + const configContent = fs.readFileSync(configXmlPath, 'utf-8'); + const descMatch = configContent.match(/(.*?)<\/description>/s); + if (descMatch) { + description = descMatch[1].trim(); + } + } + + const themeName = dir.name; + const themePath = path.join(themesDir, dir.name); + // Use URL starting with / to work with basePath concatenation (basePath + /files/... = ./files/...) + // This is consistent with iDevice URLs which also start with / + const themeUrl = `/files/perm/themes/base/${themeName}`; + + // Parse more data from config.xml if available + let title = themeName.charAt(0).toUpperCase() + themeName.slice(1); + let downloadable = '0'; + if (fs.existsSync(configXmlPath)) { + const configContent = fs.readFileSync(configXmlPath, 'utf-8'); + const titleMatch = configContent.match(/(.*?)<\/title>/s); + if (titleMatch) { + title = titleMatch[1].trim(); + } + const downloadableMatch = configContent.match(/<downloadable>(.*?)<\/downloadable>/s); + if (downloadableMatch) { + downloadable = downloadableMatch[1].trim(); + } + } + + // Scan theme icons + const icons = scanThemeIcons(themePath, themeUrl); + + themes.push({ + id: themeName, + name: themeName, + dirName: themeName, + title: title, + type: 'base', // All themes in base/ folder are base themes + url: themeUrl, + description: description || `${title} theme`, + valid: hasConfig, + downloadable: downloadable, + cssFiles: ['style.css'], // Default CSS file + icons: icons, + }); + } + } + + console.log(` Found ${themes.length} themes`); + return { themes }; +} + +/** + * Process a Nunjucks template file and convert to static HTML + * Replaces Nunjucks syntax with static values + */ +function processNjkTemplate(filePath: string): string { + if (!fs.existsSync(filePath)) { + console.warn(` Template not found: ${filePath}`); + return ''; + } + + let content = fs.readFileSync(filePath, 'utf-8'); + + // Remove Nunjucks comments {# ... #} (can span multiple lines) + content = content.replace(/\{#[\s\S]*?#\}/g, ''); + + // Replace {{ 'string' | trans }} with 'string' + content = content.replace(/\{\{\s*['"]([^'"]+)['"]\s*\|\s*trans\s*\}\}/g, '$1'); + + // Replace {{ t.something or 'default' }} with 'default' + content = content.replace(/\{\{\s*t\.\w+\s+or\s+['"]([^'"]+)['"]\s*\}\}/g, '$1'); + + // Replace {{ basePath }}/path with ./path (relative paths for static mode) + content = content.replace(/\{\{\s*basePath\s*\}\}\//g, './'); + + // Replace {{ 'path' | asset }} with ./path (relative paths for static mode) + // Matches both single and double quotes + content = content.replace(/\{\{\s*['"]([^'"]+)['"]\s*\|\s*asset\s*\}\}/g, './$1'); + + // Replace other simple {{ variable }} patterns (remove them for static) + content = content.replace(/\{\{[^}]+\}\}/g, ''); + + // Process conditionals for isOfflineInstallation (true in static mode): + // KEEP content inside {% if config.isOfflineInstallation %}...{% endif %} + content = content.replace( + /\{%\s*if\s+config\.isOfflineInstallation\s*%\}([\s\S]*?)\{%\s*endif\s*%\}/g, + '$1' + ); + // REMOVE content inside {% if not config.isOfflineInstallation %}...{% endif %} + content = content.replace( + /\{%\s*if\s+not\s+config\.isOfflineInstallation\s*%\}[\s\S]*?\{%\s*endif\s*%\}/g, + '' + ); + // Process conditionals for platformIntegration (false in static mode): + // REMOVE content inside {% if config.platformIntegration %}...{% endif %} + content = content.replace( + /\{%\s*if\s+config\.platformIntegration\s*%\}[\s\S]*?\{%\s*endif\s*%\}/g, + '' + ); + + // REMOVE user-related conditionals (no user in static mode) + // Matches {% if user.something %}...{% else %}...{% endif %} or {% if user.something %}...{% endif %} + content = content.replace( + /\{%\s*if\s+user\.\w+\s*%\}[\s\S]*?\{%\s*endif\s*%\}/g, + '' + ); + + // Remove remaining {% ... %} tags (other conditionals, includes, etc.) + content = content.replace(/\{%[\s\S]*?%\}/g, ''); + + return content; +} + +/** + * Generate the menu structure HTML + */ +function generateMenuStructureHtml(): string { + return processNjkTemplate(path.join(projectRoot, 'views/workarea/menus/menuStructure.njk')); +} + +/** + * Generate the iDevices menu HTML + */ +function generateMenuIdevicesHtml(): string { + return processNjkTemplate(path.join(projectRoot, 'views/workarea/menus/menuIdevices.njk')); +} + +/** + * Generate the head top menu HTML + */ +function generateMenuHeadTopHtml(): string { + // Process main head top template + let content = processNjkTemplate(path.join(projectRoot, 'views/workarea/menus/menuHeadTop.njk')); + + // Also include navbar + const navbarContent = processNjkTemplate(path.join(projectRoot, 'views/workarea/menus/menuNavbar.njk')); + content = content.replace('</div>', navbarContent + '</div>'); + + return content; +} + +/** + * Generate the head bottom menu HTML + */ +function generateMenuHeadBottomHtml(): string { + return processNjkTemplate(path.join(projectRoot, 'views/workarea/menus/menuHeadBottom.njk')); +} + +/** + * Read and convert Nunjucks modal templates to static HTML + * Replaces {{ 'string' | trans }} with the string itself + */ +function generateModalsHtml(): string { + const modalsDir = path.join(projectRoot, 'views/workarea/modals'); + const modalFiles = [ + 'generic/modalAlert.njk', + 'generic/modalInfo.njk', + 'generic/modalConfirm.njk', + 'generic/modalSessionLogout.njk', + 'pages/uploadtodrive.njk', + 'pages/uploadtodropbox.njk', + 'pages/filemanager.njk', + 'pages/stylemanager.njk', + 'pages/idevicemanager.njk', + 'pages/odebrokenlinks.njk', + 'pages/odeusedfiles.njk', + 'pages/lopd.njk', + 'pages/assistant.njk', + 'pages/releasenotes.njk', + 'pages/legalnotes.njk', + 'pages/about.njk', + 'pages/easteregg.njk', + 'pages/properties.njk', + 'pages/openuserodefiles.njk', + 'pages/templateselection.njk', + 'pages/modalShare.njk', + 'pages/printpreview.njk', + ]; + + let modalsHtml = ''; + for (const modalFile of modalFiles) { + modalsHtml += processNjkTemplate(path.join(modalsDir, modalFile)) + '\n'; + } + return modalsHtml; +} + +/** + * Build API parameters object (minimal version for static mode) + */ +interface ApiParameters { + routes: Record<string, { path: string; methods: string[] }>; + userPreferencesConfig: Record<string, unknown>; + ideviceInfoFieldsConfig: Record<string, unknown>; + themeInfoFieldsConfig: Record<string, unknown>; + themeEditionFieldsConfig: Record<string, unknown>; + odeProjectSyncPropertiesConfig: Record<string, unknown>; + odeProjectSyncCataloguingConfig: Record<string, unknown>; + odeComponentsSyncPropertiesConfig: Record<string, unknown>; + odeNavStructureSyncPropertiesConfig: Record<string, unknown>; + odePagStructureSyncPropertiesConfig: Record<string, unknown>; +} + +// Package locales for project language selection +const PACKAGE_LOCALES: Record<string, string> = { + ca: 'Català', + en: 'English', + eo: 'Esperanto', + es: 'Español', + eu: 'Euskara', + gl: 'Galego', + pt: 'Português', + ro: 'Română', + va: 'Valencià', +}; + +// Available licenses +const LICENSES: Record<string, string> = { + 'creative commons: attribution 4.0': 'creative commons: attribution 4.0 (BY)', + 'creative commons: attribution - share alike 4.0': 'creative commons: attribution - share alike 4.0 (BY-SA)', + 'creative commons: attribution - non derived work 4.0': 'creative commons: attribution - non derived work 4.0 (BY-ND)', + 'creative commons: attribution - non commercial 4.0': 'creative commons: attribution - non commercial 4.0 (BY-NC)', + 'creative commons: attribution - non commercial - share alike 4.0': 'creative commons: attribution - non commercial - share alike 4.0 (BY-NC-SA)', + 'creative commons: attribution - non derived work - non commercial 4.0': 'creative commons: attribution - non derived work - non commercial 4.0 (BY-NC-ND)', + 'public domain': 'public domain', + 'propietary license': 'proprietary license', +}; + +function buildApiParameters(): ApiParameters { + // Group titles for project properties + const GROUPS_TITLE = { + properties_package: 'Content metadata', + export: 'Export options', + custom_code: 'Custom code', + }; + + // In static mode, we only need a minimal set of routes that are stubbed client-side + return { + routes: { + // Translation routes (handled by DataProvider) + api_translations_lists: { path: '/api/translations/lists', methods: ['GET'] }, + api_translations_list_by_locale: { path: '/api/translations/{locale}', methods: ['GET'] }, + + // iDevices and themes (handled by DataProvider) + api_idevices_installed: { path: '/api/idevices/installed', methods: ['GET'] }, + api_themes_installed: { path: '/api/themes/installed', methods: ['GET'] }, + + // Upload limits (handled by DataProvider) + api_config_upload_limits: { path: '/api/config/upload-limits', methods: ['GET'] }, + }, + // User preferences configuration (required by frontend) + userPreferencesConfig: { + locale: { + title: 'Language', + help: 'You can choose a different language for the current project.', + value: null, + type: 'select', + options: PACKAGE_LOCALES, + category: 'General settings', + }, + advancedMode: { + title: 'Advanced mode', + value: 'true', + type: 'checkbox', + hide: true, + category: 'General settings', + }, + defaultLicense: { + title: 'License for the new documents', + help: 'You can choose a different licence for the current project.', + value: 'creative commons: attribution - share alike 4.0', + type: 'select', + options: LICENSES, + category: 'General settings', + }, + theme: { + title: 'Style', + value: 'base', + type: 'text', + hide: true, + category: 'General settings', + }, + versionControl: { + title: 'Version control', + value: 'true', + type: 'checkbox', + category: 'General settings', + }, + }, + // iDevice info fields configuration + ideviceInfoFieldsConfig: { + title: { title: 'Title', tag: 'text' }, + description: { title: 'Description', tag: 'textarea' }, + version: { title: 'Version', tag: 'text' }, + author: { title: 'Authorship', tag: 'text' }, + authorUrl: { title: 'Author URL', tag: 'text' }, + license: { title: 'License', tag: 'textarea' }, + licenseUrl: { title: 'License URL', tag: 'textarea' }, + }, + // Theme info fields configuration + themeInfoFieldsConfig: { + title: { title: 'Title', tag: 'text' }, + description: { title: 'Description', tag: 'textarea' }, + version: { title: 'Version', tag: 'text' }, + author: { title: 'Authorship', tag: 'text' }, + authorUrl: { title: 'Author URL', tag: 'text' }, + license: { title: 'License', tag: 'textarea' }, + licenseUrl: { title: 'License URL', tag: 'textarea' }, + }, + // Theme edition fields configuration (for style manager) + themeEditionFieldsConfig: { + // Empty config - theme editing disabled in static mode + }, + // Project properties configuration (required by projectProperties.js) + odeProjectSyncPropertiesConfig: { + properties: { + pp_title: { + title: 'Title', + help: 'The name given to the resource.', + value: '', + alwaysVisible: true, + type: 'text', + category: 'properties', + groups: { properties_package: GROUPS_TITLE.properties_package }, + }, + pp_subtitle: { + title: 'Subtitle', + help: 'Adds additional information to the main title.', + value: '', + alwaysVisible: true, + type: 'text', + category: 'properties', + groups: { properties_package: GROUPS_TITLE.properties_package }, + }, + pp_lang: { + title: 'Language', + help: 'Select a language.', + value: 'en', + alwaysVisible: true, + type: 'select', + options: PACKAGE_LOCALES, + category: 'properties', + groups: { properties_package: GROUPS_TITLE.properties_package }, + }, + pp_author: { + title: 'Authorship', + help: 'Primary author/s of the resource.', + value: '', + alwaysVisible: true, + type: 'text', + category: 'properties', + groups: { properties_package: GROUPS_TITLE.properties_package }, + }, + pp_license: { + title: 'License', + value: 'creative commons: attribution - share alike 4.0', + alwaysVisible: true, + type: 'select', + options: LICENSES, + category: 'properties', + groups: { properties_package: GROUPS_TITLE.properties_package }, + }, + pp_description: { + title: 'Description', + value: '', + alwaysVisible: true, + type: 'textarea', + category: 'properties', + groups: { properties_package: GROUPS_TITLE.properties_package }, + }, + exportSource: { + title: 'Editable export', + help: 'The exported content will be editable with eXeLearning.', + value: 'true', + type: 'checkbox', + category: 'properties', + groups: { export: GROUPS_TITLE.export }, + }, + pp_addExeLink: { + title: '"Made with eXeLearning" link', + help: 'Help us spreading eXeLearning.', + value: 'true', + type: 'checkbox', + category: 'properties', + groups: { export: GROUPS_TITLE.export }, + }, + pp_addPagination: { + title: 'Page counter', + help: 'A text with the page number will be added on each page.', + value: 'false', + type: 'checkbox', + category: 'properties', + groups: { export: GROUPS_TITLE.export }, + }, + pp_addSearchBox: { + title: 'Search bar (Website export only)', + help: 'A search box will be added to every page of the website.', + value: 'false', + type: 'checkbox', + category: 'properties', + groups: { export: GROUPS_TITLE.export }, + }, + pp_addAccessibilityToolbar: { + title: 'Accessibility toolbar', + help: 'The accessibility toolbar allows visitors to manipulate some aspects of your site.', + value: 'false', + type: 'checkbox', + category: 'properties', + groups: { export: GROUPS_TITLE.export }, + }, + pp_addMathJax: { + title: 'MathJax (formulas)', + help: 'Always include the MathJax library for mathematical formulas.', + value: 'false', + type: 'checkbox', + category: 'properties', + groups: { export: GROUPS_TITLE.export }, + }, + pp_extraHeadContent: { + title: 'HEAD', + help: 'HTML to be included at the end of HEAD.', + value: '', + alwaysVisible: true, + type: 'textarea', + category: 'properties', + groups: { custom_code: GROUPS_TITLE.custom_code }, + }, + footer: { + title: 'Page footer', + help: 'Type any HTML. It will be placed after every page content.', + value: '', + alwaysVisible: true, + type: 'textarea', + category: 'properties', + groups: { custom_code: GROUPS_TITLE.custom_code }, + }, + }, + }, + // Cataloguing configuration (LOM - deprecated, kept empty for backwards compatibility) + odeProjectSyncCataloguingConfig: {}, + // Component properties + odeComponentsSyncPropertiesConfig: { + visibility: { title: 'Visible in export', value: 'true', type: 'checkbox', category: null, heritable: true }, + teacherOnly: { title: 'Teacher only', value: 'false', type: 'checkbox', category: null, heritable: true }, + identifier: { title: 'ID', value: '', type: 'text', category: null, heritable: false }, + cssClass: { title: 'CSS Class', value: '', type: 'text', category: null, heritable: true }, + }, + // Navigation structure properties + odeNavStructureSyncPropertiesConfig: { + titleNode: { title: 'Title', value: '', type: 'text', category: 'General', heritable: false }, + hidePageTitle: { title: 'Hide page title', type: 'checkbox', category: 'General', value: 'false', heritable: false }, + titleHtml: { title: 'Title HTML', value: '', type: 'text', category: 'Advanced (SEO)', heritable: false }, + editableInPage: { title: 'Different title on the page', type: 'checkbox', category: 'General', value: 'false', alwaysVisible: true }, + titlePage: { title: 'Title in page', value: '', type: 'text', category: 'General', heritable: false }, + visibility: { title: 'Visible in export', value: 'true', type: 'checkbox', category: 'General', heritable: true }, + highlight: { title: 'Highlight this page', value: 'false', type: 'checkbox', category: 'General', heritable: false }, + description: { title: 'Description', value: '', type: 'textarea', category: 'Advanced (SEO)', heritable: false }, + }, + // Block/page structure properties + odePagStructureSyncPropertiesConfig: { + identifier: { title: 'ID', value: '', type: 'text', category: null, heritable: false }, + visibility: { title: 'Visible in export', value: 'true', type: 'checkbox', category: null, heritable: true }, + teacherOnly: { title: 'Teacher only', value: 'false', type: 'checkbox', category: null, heritable: true }, + allowToggle: { title: 'Allows to minimize/display content', value: 'true', type: 'checkbox', category: null, heritable: true }, + minimized: { title: 'Start minimized', value: 'false', type: 'checkbox', category: null, heritable: true }, + cssClass: { title: 'CSS Class', value: '', type: 'text', category: null, heritable: true }, + }, + }; +} + +/** + * Generate the static index.html + */ +function generateStaticHtml(bundleData: object): string { + const workareaTemplate = fs.readFileSync( + path.join(projectRoot, 'views/workarea/workarea.njk'), + 'utf-8' + ); + + // Build a simplified static HTML version + // We can't use Nunjucks at runtime, so we pre-render a static version + return `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta name="theme-color" content="#00a99d"> + <meta name="description" content="Create interactive educational content offline. Open source authoring tool for educators."> + <title>eXeLearning - Static Editor + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ eXeLearning ${buildVersion} +
+ + +
+ +
+ +
+
+
+
+
+
+

+
+
+
+
+ +
+
+
+ + +
+
+ +
+ + + + + +
+ ${generateModalsHtml()} +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; +} + +/** + * Generate PWA manifest.json + * Creates a complete manifest for installable PWA + */ +function generatePwaManifest(): string { + return JSON.stringify({ + name: `eXeLearning Editor (${buildVersion})`, + short_name: 'eXeLearning', + description: 'Create interactive educational content offline. Open source authoring tool for educators.', + start_url: './index.html', + scope: './', + display: 'standalone', + orientation: 'any', + background_color: '#ffffff', + theme_color: '#00a99d', + categories: ['education', 'productivity'], + lang: 'en', + dir: 'ltr', + icons: [ + { + src: './favicon.ico', + sizes: '48x48', + type: 'image/x-icon', + }, + { + src: './exelearning.png', + sizes: '96x96', + type: 'image/png', + purpose: 'any', + }, + { + src: './images/logo.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'any maskable', + }, + ], + file_handlers: [ + { + action: './index.html', + accept: { + 'application/x-exelearning': ['.elpx', '.elp'], + }, + }, + ], + share_target: { + action: './index.html', + method: 'POST', + enctype: 'multipart/form-data', + params: { + files: [ + { + name: 'file', + accept: ['.elpx', '.elp', 'application/zip'], + }, + ], + }, + }, + launch_handler: { + client_mode: 'navigate-existing', + }, + id: `exelearning-${buildVersion}-${buildHash}`, + }, null, 2); +} + +/** + * Generate service worker + */ +function generateServiceWorker(): string { + return `/** + * Service Worker for eXeLearning Static Mode + * Provides offline-first caching for PWA + */ + +const CACHE_NAME = 'exelearning-static-${buildVersion}-${buildHash}'; +const STATIC_ASSETS = [ + './', + './index.html', + './app/app.bundle.js', + './app/yjs/exporters.bundle.js', + './libs/yjs/yjs.min.js', + './libs/yjs/y-indexeddb.min.js', + './libs/fflate/fflate.umd.js', + './libs/jquery/jquery.min.js', + './libs/bootstrap/bootstrap.bundle.min.js', + './libs/bootstrap/bootstrap.min.css', + './style/workarea/main.css', + './style/workarea/base.css', + './data/bundle.json', +]; + +// Install: Cache all static assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => { + console.log('[SW] Caching static assets'); + return cache.addAll(STATIC_ASSETS); + }) + .then(() => self.skipWaiting()) + ); +}); + +// Activate: Clean old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then(keys => { + return Promise.all( + keys.filter(key => key.startsWith('exelearning-static-') && key !== CACHE_NAME) + .map(key => { + console.log('[SW] Deleting old cache:', key); + return caches.delete(key); + }) + ); + }).then(() => self.clients.claim()) + ); +}); + +// Fetch: Cache-first strategy +self.addEventListener('fetch', (event) => { + // Skip non-GET requests + if (event.request.method !== 'GET') return; + + event.respondWith( + caches.match(event.request) + .then(cached => { + if (cached) { + return cached; + } + + return fetch(event.request).then(response => { + // Cache successful responses + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_NAME).then(cache => { + cache.put(event.request, clone); + }); + } + return response; + }); + }) + .catch(() => { + // Offline fallback for navigation + if (event.request.mode === 'navigate') { + return caches.match('./index.html'); + } + }) + ); +}); +`; +} + +/** + * Copy directory recursively + * @param src - Source directory + * @param dest - Destination directory + * @param exclude - Directory/file names to exclude (exact match) + * @param excludePatterns - File patterns to exclude (e.g., '.test.js', '.spec.js') + */ +function copyDirRecursive( + src: string, + dest: string, + exclude: string[] = [], + excludePatterns: string[] = ['.test.js', '.spec.js'] +) { + if (!fs.existsSync(src)) { + console.warn(`Source not found: ${src}`); + return; + } + + fs.mkdirSync(dest, { recursive: true }); + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + if (exclude.includes(entry.name)) continue; + // Skip test files + if (excludePatterns.some((pattern) => entry.name.endsWith(pattern))) continue; + + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + copyDirRecursive(srcPath, destPath, exclude, excludePatterns); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +/** + * Main build function + */ +async function buildStaticBundle() { + console.log('='.repeat(60)); + console.log('Building Static Distribution'); + console.log(`Version: ${buildVersion} (${buildHash})`); + console.log('='.repeat(60)); + + // Clean output directory + if (fs.existsSync(outputDir)) { + fs.rmSync(outputDir, { recursive: true }); + } + fs.mkdirSync(outputDir, { recursive: true }); + + // 1. Load and serialize API data + console.log('\n1. Loading API data...'); + const apiParameters = buildApiParameters(); + const translations = loadAllTranslations(); + const idevices = buildIdevicesList(); + const themes = buildThemesList(); + + // Read existing bundle manifest + const bundleManifestPath = path.join(projectRoot, 'public/bundles/manifest.json'); + let bundleManifest = null; + if (fs.existsSync(bundleManifestPath)) { + bundleManifest = JSON.parse(fs.readFileSync(bundleManifestPath, 'utf-8')); + } + + const bundleData = { + version: buildVersion, + builtAt: new Date().toISOString(), + parameters: apiParameters, + translations, + idevices, + themes, + bundleManifest, + }; + + // Write bundle.json + const dataDir = path.join(outputDir, 'data'); + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync( + path.join(dataDir, 'bundle.json'), + JSON.stringify(bundleData, null, 2) + ); + console.log(' Created data/bundle.json'); + + // 2. Generate static HTML + console.log('\n2. Generating static HTML...'); + const staticHtml = generateStaticHtml(bundleData); + fs.writeFileSync(path.join(outputDir, 'index.html'), staticHtml); + console.log(' Created index.html'); + + // 3. Generate PWA files + console.log('\n3. Generating PWA files...'); + fs.writeFileSync(path.join(outputDir, 'manifest.json'), generatePwaManifest()); + fs.writeFileSync(path.join(outputDir, 'service-worker.js'), generateServiceWorker()); + console.log(' Created manifest.json'); + console.log(' Created service-worker.js'); + + // 4. Copy static assets + console.log('\n4. Copying static assets...'); + + // Copy app folder + copyDirRecursive( + path.join(projectRoot, 'public/app'), + path.join(outputDir, 'app'), + ['test', 'spec'] + ); + console.log(' Copied app/'); + + // Copy libs folder + copyDirRecursive( + path.join(projectRoot, 'public/libs'), + path.join(outputDir, 'libs') + ); + console.log(' Copied libs/'); + + // Copy style folder + copyDirRecursive( + path.join(projectRoot, 'public/style'), + path.join(outputDir, 'style') + ); + console.log(' Copied style/'); + + // Copy bundles folder (pre-built resource ZIPs) + copyDirRecursive( + path.join(projectRoot, 'public/bundles'), + path.join(outputDir, 'bundles') + ); + console.log(' Copied bundles/'); + + // Copy files/perm (themes, iDevices, favicon) + copyDirRecursive( + path.join(projectRoot, 'public/files/perm'), + path.join(outputDir, 'files/perm') + ); + console.log(' Copied files/perm/'); + + // Copy images folder (default-avatar.svg, logo.svg, etc.) + copyDirRecursive( + path.join(projectRoot, 'public/images'), + path.join(outputDir, 'images') + ); + console.log(' Copied images/'); + + // Copy exelearning.png to root + const exelearningPng = path.join(projectRoot, 'public/exelearning.png'); + if (fs.existsSync(exelearningPng)) { + fs.copyFileSync(exelearningPng, path.join(outputDir, 'exelearning.png')); + console.log(' Copied exelearning.png'); + } + + // Copy favicon.ico + const faviconIco = path.join(projectRoot, 'public/favicon.ico'); + if (fs.existsSync(faviconIco)) { + fs.copyFileSync(faviconIco, path.join(outputDir, 'favicon.ico')); + console.log(' Copied favicon.ico'); + } + + // Copy CHANGELOG.md + const changelogMd = path.join(projectRoot, 'public/CHANGELOG.md'); + if (fs.existsSync(changelogMd)) { + fs.copyFileSync(changelogMd, path.join(outputDir, 'CHANGELOG.md')); + console.log(' Copied CHANGELOG.md'); + } + + console.log('\n' + '='.repeat(60)); + console.log('Static distribution built successfully!'); + console.log(`Output: ${outputDir}`); + console.log('='.repeat(60)); +} + +// Run build +buildStaticBundle().catch(console.error); diff --git a/scripts/serve-static-for-e2e.ts b/scripts/serve-static-for-e2e.ts new file mode 100644 index 000000000..4cb58fb24 --- /dev/null +++ b/scripts/serve-static-for-e2e.ts @@ -0,0 +1,31 @@ +/** + * E2E Static Server Script + * + * Builds the static bundle and serves it for E2E testing. + * Used by Playwright's webServer configuration for static mode tests. + */ +import { $ } from 'bun'; + +const PORT = process.env.PORT || '8080'; + +console.log('[E2E Static] Building static bundle...'); + +// Build static distribution +try { + await $`bun run build:static`.quiet(); + console.log('[E2E Static] Build completed successfully'); +} catch (error) { + console.error('[E2E Static] Build failed:', error); + process.exit(1); +} + +console.log(`[E2E Static] Starting server on port ${PORT}...`); + +// Serve static files with SPA mode (-s flag) +const proc = Bun.spawn(['bunx', 'serve', 'dist/static', '-p', PORT, '-s'], { + stdout: 'inherit', + stderr: 'inherit', +}); + +// Keep process running +await proc.exited; diff --git a/src/index.ts b/src/index.ts index b5f31479a..370b951ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -257,39 +257,10 @@ const app = new Elysia() } } - // Match /v{version}/user-files/themes/* and serve from FILES_DIR - const versionedUserFilesMatch = pathname.match(/^\/v[\d.]+[^/]*\/user-files\/themes\/(.+)$/); - if (versionedUserFilesMatch) { - const relativePath = versionedUserFilesMatch[1]; - const filesDir = getFilesDir(); - const filePath = path.join(filesDir, 'themes', 'users', relativePath); - - // Security check - const resolvedPath = path.resolve(filePath); - const resolvedBase = path.resolve(path.join(filesDir, 'themes', 'users')); - if (resolvedPath.startsWith(resolvedBase) && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { - const content = fs.readFileSync(filePath); - const ext = path.extname(filePath).toLowerCase(); - const contentType = MIME_TYPES[ext] || 'application/octet-stream'; - - return new Response(content, { - headers: { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=31536000', - }, - }); - } - } - - // Match /v{version}/* and rewrite to /* (except /libs, /admin-files, /user-files which are handled above) + // Match /v{version}/* and rewrite to /* (except /libs, /admin-files which are handled above) // This handles /app/*, /style/*, and other versioned static assets const versionedMatch = pathname.match(/^\/v[\d.]+[^/]*\/(.+)$/); - if ( - versionedMatch && - !versionedMatch[1].startsWith('libs/') && - !versionedMatch[1].startsWith('admin-files/') && - !versionedMatch[1].startsWith('user-files/') - ) { + if (versionedMatch && !versionedMatch[1].startsWith('libs/') && !versionedMatch[1].startsWith('admin-files/')) { const filePath = path.join(process.cwd(), 'public', versionedMatch[1]); if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath); @@ -413,35 +384,6 @@ const app = new Elysia() set.status = 404; return 'Not Found'; }) - // Serve user theme files from FILES_DIR/themes/users/ - // URL pattern: /user-files/themes/{dirName}/* or /{version}/user-files/themes/{dirName}/* - .get('/user-files/themes/*', ({ params, set }) => { - const relativePath = params['*'] || ''; - const filesDir = getFilesDir(); - const filePath = path.join(filesDir, 'themes', 'users', relativePath); - - // Security: ensure path is within the themes/users directory - const resolvedPath = path.resolve(filePath); - const resolvedBase = path.resolve(path.join(filesDir, 'themes', 'users')); - if (!resolvedPath.startsWith(resolvedBase)) { - set.status = 403; - return 'Forbidden'; - } - - if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { - const content = fs.readFileSync(filePath); - const ext = path.extname(filePath).toLowerCase(); - const contentType = MIME_TYPES[ext] || 'application/octet-stream'; - - set.headers['Content-Type'] = contentType; - set.headers['Content-Length'] = content.length.toString(); - set.headers['Cache-Control'] = 'public, max-age=31536000'; // 1 year cache - return content; - } - - set.status = 404; - return 'Not Found'; - }) // Static files from public directory (served at root, BASE_PATH handled in onRequest) .use( staticPlugin({ diff --git a/src/routes/config.ts b/src/routes/config.ts index 54dc9909f..a6a007022 100644 --- a/src/routes/config.ts +++ b/src/routes/config.ts @@ -140,6 +140,7 @@ const ODE_COMPONENTS_SYNC_PROPERTIES_CONFIG = { }, identifier: { title: `${TRANS_PREFIX}ID`, + value: '', type: 'text', category: null, heritable: false, @@ -159,6 +160,7 @@ const ODE_COMPONENTS_SYNC_PROPERTIES_CONFIG = { const ODE_NAV_STRUCTURE_SYNC_PROPERTIES_CONFIG = { titleNode: { title: `${TRANS_PREFIX}Title`, + value: '', type: 'text', category: `${TRANS_PREFIX}General`, heritable: false, @@ -172,6 +174,7 @@ const ODE_NAV_STRUCTURE_SYNC_PROPERTIES_CONFIG = { }, titleHtml: { title: `${TRANS_PREFIX}Title HTML`, + value: '', type: 'text', category: `${TRANS_PREFIX}Advanced (SEO)`, heritable: false, @@ -185,6 +188,7 @@ const ODE_NAV_STRUCTURE_SYNC_PROPERTIES_CONFIG = { }, titlePage: { title: `${TRANS_PREFIX}Title in page`, + value: '', type: 'text', category: `${TRANS_PREFIX}General`, heritable: false, @@ -205,6 +209,7 @@ const ODE_NAV_STRUCTURE_SYNC_PROPERTIES_CONFIG = { }, description: { title: `${TRANS_PREFIX}Description`, + value: '', type: 'textarea', category: `${TRANS_PREFIX}Advanced (SEO)`, heritable: false, @@ -245,6 +250,7 @@ const ODE_PAG_STRUCTURE_SYNC_PROPERTIES_CONFIG = { }, identifier: { title: `${TRANS_PREFIX}ID`, + value: '', type: 'text', category: null, heritable: false, diff --git a/src/routes/idevices.ts b/src/routes/idevices.ts index b120fcaa7..e6e27906a 100644 --- a/src/routes/idevices.ts +++ b/src/routes/idevices.ts @@ -326,19 +326,10 @@ export const idevicesRoutes = new Elysia({ name: 'idevices-routes' }) // Security: prevent path traversal const cleanResource = resource.replace(/\.\./g, '').replace(/^\/+/, ''); - let filePath = path.join('public/files', cleanResource); - let resolvedPath = path.resolve(filePath); - let basePath = path.resolve('public/files'); - - // Check if file exists in public/files, if not check FILES_DIR for user themes - if (!fs.existsSync(filePath) && cleanResource.startsWith('perm/themes/users/')) { - // User themes may be in FILES_DIR/themes/users/ instead of public/files/perm/themes/users/ - const filesDir = process.env.ELYSIA_FILES_DIR || process.env.FILES_DIR || '/mnt/data'; - const themeRelativePath = cleanResource.replace('perm/themes/users/', ''); - filePath = path.join(filesDir, 'themes', 'users', themeRelativePath); - resolvedPath = path.resolve(filePath); - basePath = path.resolve(path.join(filesDir, 'themes', 'users')); - } + // Note: User themes are stored client-side in IndexedDB, not on server + const filePath = path.join('public/files', cleanResource); + const resolvedPath = path.resolve(filePath); + const basePath = path.resolve('public/files'); // Additional security check if (!resolvedPath.startsWith(basePath)) { diff --git a/src/routes/pages.ts b/src/routes/pages.ts index dc81604a1..d72bd3587 100644 --- a/src/routes/pages.ts +++ b/src/routes/pages.ts @@ -688,6 +688,8 @@ export function createPagesRoutes(deps: PagesDependencies = defaultDependencies) // Unified config object (replaces legacy 'symfony' object) const config = { + // Version for cache busting in preview and asset URLs + version: getAppVersion(), // Platform settings platformName: platformName, platformType: 'standalone', diff --git a/src/routes/resources.spec.ts b/src/routes/resources.spec.ts index f2acfc66c..831cbe20d 100644 --- a/src/routes/resources.spec.ts +++ b/src/routes/resources.spec.ts @@ -80,41 +80,14 @@ describe('Resources Routes', () => { expect(imgFiles.length).toBeGreaterThan(0); }); - it('should check user themes first', async () => { - configure({ - fs: { - existsSync: (filePath: string) => { - if (filePath === 'public/files/perm/themes/users/custom-theme') return true; - if (filePath === 'public/files/perm/themes/base/custom-theme') return false; - return fs.existsSync(filePath); - }, - readdirSync: (dirPath: any, options?: any) => { - if (typeof dirPath === 'string' && dirPath.includes('users/custom-theme')) { - return [ - { name: 'style.css', isFile: () => true, isDirectory: () => false }, - ] as unknown as fs.Dirent[]; - } - return fs.readdirSync(dirPath, options); - }, - statSync: fs.statSync, - readFileSync: fs.readFileSync, - }, - }); - app = new Elysia().use(resourcesRoutes); - - const res = await app.handle(new Request('http://localhost/api/resources/theme/custom-theme')); + // Note: User themes are stored client-side in IndexedDB, not on server + // Tests for user themes have been removed - see themesManager.test.js for client-side tests - expect(res.status).toBe(200); - const body = await res.json(); - expect(body[0].url).toContain('/themes/users/custom-theme'); - }); - - it('should return admin themes from FILES_DIR', async () => { + it('should return site themes from FILES_DIR', async () => { configure({ fs: { existsSync: (filePath: string) => { - // User and base themes don't exist - if (filePath === 'public/files/perm/themes/users/site-custom-theme') return false; + // Base theme doesn't exist if (filePath === 'public/files/perm/themes/base/site-custom-theme') return false; // Site theme exists if (filePath === '/tmp/test-files/themes/site/site-custom-theme') return true; @@ -512,7 +485,6 @@ describe('Resources Routes', () => { fs: { existsSync: (filePath: string) => { if (filePath === 'public/files/perm/themes/base/test-hidden') return true; - if (filePath === 'public/files/perm/themes/users/test-hidden') return false; return fs.existsSync(filePath); }, readdirSync: (dirPath: any, options?: any) => { @@ -544,7 +516,6 @@ describe('Resources Routes', () => { fs: { existsSync: (filePath: string) => { if (filePath === 'public/files/perm/themes/base/test-recursive') return true; - if (filePath === 'public/files/perm/themes/users/test-recursive') return false; // Also need to say the img subdirectory exists for the recursive call if (filePath.includes('test-recursive/img')) return true; return fs.existsSync(filePath); @@ -585,7 +556,6 @@ describe('Resources Routes', () => { fs: { existsSync: (filePath: string) => { if (filePath === 'public/files/perm/themes/base/error-theme') return true; - if (filePath === 'public/files/perm/themes/users/error-theme') return false; return fs.existsSync(filePath); }, readdirSync: (dirPath: any, options?: any) => { @@ -814,7 +784,6 @@ describe('Resources Routes', () => { fs: { existsSync: (filePath: string) => { if (filePath.includes('bundles') && filePath.includes('.zip')) return false; - if (filePath.includes('themes/users')) return false; return fs.existsSync(filePath); }, readdirSync: fs.readdirSync, @@ -831,16 +800,19 @@ describe('Resources Routes', () => { expect(body.error).toBe('Not Found'); }); - it('should return 404 if user theme is empty', async () => { + // Note: User themes are stored client-side in IndexedDB, not on server + // Tests for user theme bundles have been converted to site theme tests + + it('should return 404 if site theme is empty', async () => { configure({ fs: { existsSync: (filePath: string) => { if (filePath.includes('bundles')) return false; - if (filePath === 'public/files/perm/themes/users/empty-theme') return true; + if (filePath === '/tmp/test-files/themes/site/empty-theme') return true; return fs.existsSync(filePath); }, readdirSync: (dirPath: any, options?: any) => { - if (typeof dirPath === 'string' && dirPath.includes('users/empty-theme')) { + if (typeof dirPath === 'string' && dirPath.includes('site/empty-theme')) { return [] as unknown as fs.Dirent[]; } return fs.readdirSync(dirPath, options); @@ -848,6 +820,10 @@ describe('Resources Routes', () => { statSync: fs.statSync, readFileSync: fs.readFileSync, }, + getEnv: (key: string) => { + if (key === 'ELYSIA_FILES_DIR' || key === 'FILES_DIR') return '/tmp/test-files'; + return process.env[key]; + }, }); app = new Elysia().use(resourcesRoutes); @@ -858,16 +834,16 @@ describe('Resources Routes', () => { expect(body.message).toContain('empty'); }); - it('should generate ZIP on-the-fly for user themes', async () => { + it('should generate ZIP on-the-fly for site themes', async () => { configure({ fs: { existsSync: (filePath: string) => { if (filePath.includes('bundles')) return false; - if (filePath === 'public/files/perm/themes/users/user-theme') return true; + if (filePath === '/tmp/test-files/themes/site/site-theme-test') return true; return fs.existsSync(filePath); }, readdirSync: (dirPath: any, options?: any) => { - if (typeof dirPath === 'string' && dirPath.includes('users/user-theme')) { + if (typeof dirPath === 'string' && dirPath.includes('site/site-theme-test')) { return [ { name: 'style.css', isFile: () => true, isDirectory: () => false }, ] as unknown as fs.Dirent[]; @@ -876,28 +852,34 @@ describe('Resources Routes', () => { }, statSync: fs.statSync, readFileSync: (filePath: string) => { - if (filePath.includes('user-theme/style.css')) { + if (filePath.includes('site-theme-test/style.css')) { return Buffer.from('body { color: red; }'); } return fs.readFileSync(filePath, 'utf-8'); }, }, + getEnv: (key: string) => { + if (key === 'ELYSIA_FILES_DIR' || key === 'FILES_DIR') return '/tmp/test-files'; + return process.env[key]; + }, }); app = new Elysia().use(resourcesRoutes); - const res = await app.handle(new Request('http://localhost/api/resources/bundle/theme/user-theme')); + const res = await app.handle( + new Request('http://localhost/api/resources/bundle/theme/site-theme-test'), + ); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('application/zip'); expect(res.headers.get('cache-control')).toContain('private'); }); - it('should skip files that cannot be read for user themes', async () => { + it('should skip files that cannot be read for site themes', async () => { configure({ fs: { existsSync: (filePath: string) => { if (filePath.includes('bundles')) return false; - if (filePath === 'public/files/perm/themes/users/theme-with-error') return true; + if (filePath === '/tmp/test-files/themes/site/theme-with-error') return true; return fs.existsSync(filePath); }, readdirSync: (dirPath: any, options?: any) => { @@ -920,6 +902,10 @@ describe('Resources Routes', () => { return fs.readFileSync(filePath, 'utf-8'); }, }, + getEnv: (key: string) => { + if (key === 'ELYSIA_FILES_DIR' || key === 'FILES_DIR') return '/tmp/test-files'; + return process.env[key]; + }, }); app = new Elysia().use(resourcesRoutes); @@ -938,8 +924,6 @@ describe('Resources Routes', () => { existsSync: (filePath: string) => { // No prebuilt bundle if (filePath.includes('bundles')) return false; - // No user theme - if (filePath.includes('themes/users')) return false; // Site theme exists if (filePath === '/tmp/test-files/themes/site/site-theme') return true; return fs.existsSync(filePath); @@ -978,12 +962,11 @@ describe('Resources Routes', () => { expect(res.headers.get('cache-control')).toContain('private'); }); - it('should return 404 if site theme is empty', async () => { + it('should return 404 if admin/site theme is empty', async () => { configure({ fs: { existsSync: (filePath: string) => { if (filePath.includes('bundles')) return false; - if (filePath.includes('themes/users')) return false; if (filePath === '/tmp/test-files/themes/site/empty-site-theme') return true; return fs.existsSync(filePath); }, diff --git a/src/routes/resources.ts b/src/routes/resources.ts index 9684323c3..60cba99c0 100644 --- a/src/routes/resources.ts +++ b/src/routes/resources.ts @@ -10,7 +10,7 @@ import { LEGACY_IDEVICE_MAPPING } from '../shared/export/constants'; // Base paths for resources const THEMES_BASE_PATH = 'public/files/perm/themes/base'; -const THEMES_USERS_PATH = 'public/files/perm/themes/users'; +// Note: User themes are stored client-side in IndexedDB, not on server const IDEVICES_BASE_PATH = 'public/files/perm/idevices/base'; const IDEVICES_USERS_PATH = 'public/files/perm/idevices/users'; const LIBS_PATH = 'public/libs'; @@ -136,19 +136,15 @@ function buildFileList(dirPath: string, urlPrefix: string, pathPrefix?: string): */ export const resourcesRoutes = new Elysia({ name: 'resources-routes' }) // GET /api/resources/theme/:themeName - Get all files for a theme + // Note: User themes are stored client-side in IndexedDB and served via ResourceFetcher .get('/api/resources/theme/:themeName', ({ params, set }) => { const { themeName } = params; const version = getAppVersion(); const basePath = getBasePath(); - // Check user themes first, then base themes, then admin themes - let themePath = path.join(THEMES_USERS_PATH, themeName); - let urlPrefix = `/files/perm/themes/users/${themeName}`; - - if (!deps.fs.existsSync(themePath)) { - themePath = path.join(THEMES_BASE_PATH, themeName); - urlPrefix = `/files/perm/themes/base/${themeName}`; - } + // Check base themes first + let themePath = path.join(THEMES_BASE_PATH, themeName); + const urlPrefix = `/files/perm/themes/base/${themeName}`; // Check site themes (from FILES_DIR) if (!deps.fs.existsSync(themePath)) { @@ -396,37 +392,7 @@ export const resourcesRoutes = new Elysia({ name: 'resources-routes' }) return Bun.file(prebuiltPath); } - // Check if this is a user theme that needs on-demand ZIP generation - const userThemePath = path.join(THEMES_USERS_PATH, themeName); - if (deps.fs.existsSync(userThemePath)) { - // Generate ZIP on-the-fly for user themes - const files = scanDirectory(userThemePath); - if (files.length === 0) { - set.status = 404; - return { error: 'Not Found', message: `Theme ${themeName} is empty` }; - } - - // Use fflate to create ZIP dynamically - const { zipSync } = await import('fflate'); - const zipData: { [key: string]: Uint8Array } = {}; - - for (const filePath of files) { - const fullPath = path.join(userThemePath, filePath); - try { - const content = deps.fs.readFileSync(fullPath) as Buffer; - zipData[filePath] = new Uint8Array(content); - } catch { - // Skip files that can't be read - } - } - - const zipBuffer = zipSync(zipData, { level: 6 }); - - set.headers['content-type'] = 'application/zip'; - set.headers['cache-control'] = 'private, max-age=3600'; // Shorter cache for user themes - return new Response(zipBuffer); - } - + // Note: User themes are stored client-side in IndexedDB, not on server // Check if this is a site theme that needs on-demand ZIP generation const siteThemesPath = getSiteThemesPath(); const siteThemePath = path.join(siteThemesPath, themeName); diff --git a/src/routes/themes.spec.ts b/src/routes/themes.spec.ts index ce045a555..60d4c18f2 100644 --- a/src/routes/themes.spec.ts +++ b/src/routes/themes.spec.ts @@ -2,7 +2,8 @@ * Tests for Themes Routes * * These tests work with the actual theme files in the project. - * The routes use hardcoded paths so we test against real themes. + * Only base and site themes are served from the server. + * User themes from .elpx files are stored client-side in Yjs. */ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { Elysia } from 'elysia'; @@ -83,12 +84,12 @@ describe('Themes Routes', () => { expect(typeof theme.icons).toBe('object'); }); - it('should have type as base or user', async () => { + it('should have type as base or site only', async () => { const res = await app.handle(new Request('http://localhost/api/themes/installed')); const body = await res.json(); for (const theme of body.themes) { - expect(['base', 'user']).toContain(theme.type); + expect(['base', 'site']).toContain(theme.type); } }); @@ -98,164 +99,124 @@ describe('Themes Routes', () => { const body = await res.json(); const theme = body.themes[0]; - // URLs should start with /v followed by version + // URL should start with /v followed by version number expect(theme.url).toMatch(/^\/v[\d.]+/); - expect(theme.preview).toMatch(/^\/v[\d.]+/); }); - it('should sort themes by displayName', async () => { + it('should return defaultTheme info', async () => { const res = await app.handle(new Request('http://localhost/api/themes/installed')); const body = await res.json(); - const displayNames = body.themes.map((t: any) => t.displayName); - const sorted = [...displayNames].sort((a, b) => a.localeCompare(b)); - - expect(displayNames).toEqual(sorted); + expect(body.defaultTheme).toBeDefined(); + expect(body.defaultTheme.type).toBeDefined(); + expect(body.defaultTheme.dirName).toBeDefined(); }); - it('should include base theme', async () => { - const res = await app.handle(new Request('http://localhost/api/themes/installed')); + it('should return empty array when themes path does not exist', async () => { + configure({ + fs: { + existsSync: (filePath: string) => { + if (filePath === 'public/files/perm/themes/base') return false; + return fs.existsSync(filePath); + }, + readFileSync: fs.readFileSync, + readdirSync: fs.readdirSync, + }, + }); + app = new Elysia().use(themesRoutes); + const res = await app.handle(new Request('http://localhost/api/themes/installed')); const body = await res.json(); - // The default 'base' theme should exist - const baseTheme = body.themes.find((t: any) => t.dirName === 'base'); - expect(baseTheme).toBeDefined(); - expect(baseTheme.type).toBe('base'); + expect(body.themes).toEqual([]); }); }); describe('GET /api/themes/installed/:themeId', () => { it('should return specific theme by ID', async () => { - // First get list to find a valid theme ID - const listRes = await app.handle(new Request('http://localhost/api/themes/installed')); - const listBody = await listRes.json(); - const themeId = listBody.themes[0]?.dirName; - - if (!themeId) { - // Skip test if no themes exist - return; - } - - const res = await app.handle(new Request(`http://localhost/api/themes/installed/${themeId}`)); + const res = await app.handle(new Request('http://localhost/api/themes/installed/base')); expect(res.status).toBe(200); const body = await res.json(); - expect(body.dirName).toBe(themeId); + expect(body.dirName).toBe('base'); + expect(body.type).toBe('base'); }); it('should return 404 for non-existent theme', async () => { - const res = await app.handle( - new Request('http://localhost/api/themes/installed/non-existent-theme-xyz-123'), - ); + const res = await app.handle(new Request('http://localhost/api/themes/installed/non-existent-theme')); expect(res.status).toBe(404); const body = await res.json(); expect(body.error).toBe('Not Found'); - expect(body.message).toContain('not found'); }); - it('should return full theme config for base theme', async () => { + it('should include all theme properties', async () => { const res = await app.handle(new Request('http://localhost/api/themes/installed/base')); - expect(res.status).toBe(200); const body = await res.json(); - - expect(body.dirName).toBe('base'); expect(body.name).toBeDefined(); + expect(body.dirName).toBe('base'); expect(body.displayName).toBeDefined(); expect(body.url).toBeDefined(); expect(body.cssFiles).toBeDefined(); - expect(body.valid).toBe(true); + expect(body.js).toBeDefined(); + expect(body.icons).toBeDefined(); }); + }); - it('should include metadata fields', async () => { - const res = await app.handle(new Request('http://localhost/api/themes/installed/base')); + describe('GET /api/resources/theme/:themeName/bundle', () => { + it('should return theme bundle with files', async () => { + const res = await app.handle(new Request('http://localhost/api/resources/theme/base/bundle')); + expect(res.status).toBe(200); const body = await res.json(); - - expect(body.version).toBeDefined(); - expect(body.author).toBeDefined(); - expect(body.license).toBeDefined(); - expect(body.description).toBeDefined(); + expect(body.themeName).toBe('base'); + expect(body.files).toBeDefined(); + expect(typeof body.files).toBe('object'); }); - it('should return icon definitions', async () => { - const res = await app.handle(new Request('http://localhost/api/themes/installed/base')); + it('should include CSS file in bundle', async () => { + const res = await app.handle(new Request('http://localhost/api/resources/theme/base/bundle')); const body = await res.json(); - - expect(body.icons).toBeDefined(); - expect(typeof body.icons).toBe('object'); - - // Check icon structure if icons exist - const iconKeys = Object.keys(body.icons); - if (iconKeys.length > 0) { - const firstIcon = body.icons[iconKeys[0]]; - expect(firstIcon.id).toBeDefined(); - expect(firstIcon.type).toBe('img'); - expect(firstIcon.value).toBeDefined(); - } + // Should have at least style.css + const hasStyleCss = Object.keys(body.files).some(f => f.endsWith('.css')); + expect(hasStyleCss).toBe(true); }); - it('should handle theme ID with special characters safely', async () => { - const res = await app.handle(new Request('http://localhost/api/themes/installed/../../../etc/passwd')); + it('should return 404 for non-existent theme', async () => { + const res = await app.handle(new Request('http://localhost/api/resources/theme/non-existent/bundle')); - // Should return 404, not expose filesystem expect(res.status).toBe(404); }); - }); - describe('theme icon format', () => { - it('should have correct icon structure', async () => { - const res = await app.handle(new Request('http://localhost/api/themes/installed')); + it('should encode files as base64', async () => { + const res = await app.handle(new Request('http://localhost/api/resources/theme/base/bundle')); const body = await res.json(); - // Find a theme with icons - const themeWithIcons = body.themes.find((t: any) => Object.keys(t.icons || {}).length > 0); - - if (themeWithIcons) { - const firstIconKey = Object.keys(themeWithIcons.icons)[0]; - const icon = themeWithIcons.icons[firstIconKey]; - - expect(icon.id).toBe(firstIconKey); - expect(icon.title).toBeDefined(); - expect(icon.type).toBe('img'); - expect(icon.value).toContain('/icons/'); - } + const firstFile = Object.values(body.files)[0] as string; + // Base64 strings should not contain special characters except +, /, = + expect(firstFile).toMatch(/^[A-Za-z0-9+/=]+$/); }); }); - describe('APP_VERSION environment variable', () => { - it('should use APP_VERSION when set', async () => { + describe('version handling', () => { + it('should use APP_VERSION env var when set', async () => { configure({ - getEnv: (key: string) => (key === 'APP_VERSION' ? 'v99.99.99' : undefined), + getEnv: (key: string) => (key === 'APP_VERSION' ? 'v1.2.3' : undefined), }); app = new Elysia().use(themesRoutes); const res = await app.handle(new Request('http://localhost/api/themes/installed')); const body = await res.json(); - // Theme URLs should include the custom version - const theme = body.themes[0]; - expect(theme.url).toContain('/v99.99.99/'); - expect(theme.preview).toContain('/v99.99.99/'); + if (body.themes.length > 0) { + expect(body.themes[0].url).toContain('/v1.2.3/'); + } }); - }); - describe('getAppVersion fallback', () => { - it('should return v0.0.0 when package.json cannot be read', async () => { + it('should fall back to package.json version', async () => { configure({ - fs: { - existsSync: fs.existsSync, - readFileSync: (filePath: string) => { - if (filePath === 'package.json') { - throw new Error('File not found'); - } - return fs.readFileSync(filePath, 'utf-8'); - }, - readdirSync: fs.readdirSync, - }, getEnv: () => undefined, }); app = new Elysia().use(themesRoutes); @@ -263,34 +224,27 @@ describe('Themes Routes', () => { const res = await app.handle(new Request('http://localhost/api/themes/installed')); const body = await res.json(); - // Theme URLs should include fallback version - const theme = body.themes[0]; - expect(theme.url).toContain('/v0.0.0/'); + if (body.themes.length > 0) { + // Should have some version in URL + expect(body.themes[0].url).toMatch(/^\/v[\d.]+/); + } }); - }); - describe('scanThemeFiles error handling', () => { - it('should return empty array when readdirSync throws', async () => { - let callCount = 0; + it('should fall back to v0.0.0 when package.json is invalid', async () => { configure({ + getEnv: () => undefined, fs: { - existsSync: fs.existsSync, - readFileSync: fs.readFileSync, - readdirSync: (dirPath: any, options?: any) => { - // Throw on theme directory reads for CSS/JS scanning - if ( - typeof dirPath === 'string' && - dirPath.includes('themes/base/base') && - !dirPath.includes('icons') - ) { - callCount++; - if (callCount <= 2) { - // Throw for first two calls (CSS and JS scanning) - throw new Error('Permission denied'); - } + existsSync: (p: string) => { + if (p.includes('package.json')) return true; + return fs.existsSync(p); + }, + readFileSync: (p: string, encoding?: BufferEncoding) => { + if (typeof p === 'string' && p.includes('package.json')) { + return 'invalid json {{{'; } - return fs.readdirSync(dirPath, options); + return fs.readFileSync(p, encoding); }, + readdirSync: fs.readdirSync, }, }); app = new Elysia().use(themesRoutes); @@ -298,24 +252,21 @@ describe('Themes Routes', () => { const res = await app.handle(new Request('http://localhost/api/themes/installed')); const body = await res.json(); - // Should still return themes (with default CSS file) - expect(body.themes.length).toBeGreaterThan(0); + if (body.themes.length > 0) { + expect(body.themes[0].url).toContain('/v0.0.0/'); + } }); - }); - describe('scanThemeIcons error handling', () => { - it('should return empty object when icons readdirSync throws', async () => { + it('should fall back to v0.0.0 when package.json does not exist', async () => { configure({ + getEnv: () => undefined, fs: { - existsSync: fs.existsSync, - readFileSync: fs.readFileSync, - readdirSync: (dirPath: any, options?: any) => { - // Throw on icons directory read - if (typeof dirPath === 'string' && dirPath.includes('/icons')) { - throw new Error('Permission denied'); - } - return fs.readdirSync(dirPath, options); + existsSync: (p: string) => { + if (typeof p === 'string' && p.includes('package.json')) return false; + return fs.existsSync(p); }, + readFileSync: fs.readFileSync, + readdirSync: fs.readdirSync, }, }); app = new Elysia().use(themesRoutes); @@ -323,31 +274,32 @@ describe('Themes Routes', () => { const res = await app.handle(new Request('http://localhost/api/themes/installed')); const body = await res.json(); - // Should still return themes with empty icons - expect(body.themes.length).toBeGreaterThan(0); + if (body.themes.length > 0) { + expect(body.themes[0].url).toContain('/v0.0.0/'); + } }); }); - describe('default CSS file fallback', () => { - it('should add style.css when no CSS files found', async () => { + describe('edge cases', () => { + it('should handle theme with no CSS files (falls back to style.css)', async () => { configure({ fs: { - existsSync: (filePath: string) => { - // Theme exists but no CSS files in directory - return fs.existsSync(filePath); - }, + existsSync: fs.existsSync, readFileSync: fs.readFileSync, - readdirSync: (dirPath: any, options?: any) => { - const entries = fs.readdirSync(dirPath, options); - // Filter out CSS files for theme directory + readdirSync: (dirPath: string, options?: { withFileTypes: boolean }) => { + // Return empty list for CSS scan if ( typeof dirPath === 'string' && - dirPath.includes('themes/base/base') && + dirPath.includes('themes/base/') && !dirPath.includes('icons') ) { - return entries.filter((e: any) => !e.name?.endsWith('.css')); + const entries = fs.readdirSync(dirPath, options); + // Filter out CSS files to simulate no CSS + if (Array.isArray(entries) && entries.length > 0 && typeof entries[0] === 'object') { + return (entries as fs.Dirent[]).filter(e => !e.name.endsWith('.css')); + } } - return entries; + return fs.readdirSync(dirPath, options); }, }, }); @@ -356,1321 +308,163 @@ describe('Themes Routes', () => { const res = await app.handle(new Request('http://localhost/api/themes/installed')); const body = await res.json(); - const baseTheme = body.themes.find((t: any) => t.dirName === 'base'); - expect(baseTheme?.cssFiles).toContain('style.css'); - }); - }); - - describe('theme config with optional fields', () => { - it('should parse theme with logo-img', async () => { - const customConfig = ` - - test-theme - Test Theme - 1.0 - logo.png -`; - - configure({ - fs: { - existsSync: (filePath: string) => { - if (filePath === 'public/files/perm/themes/base/test-logo/config.xml') return true; - if (filePath === 'public/files/perm/themes/users/test-logo/config.xml') return false; - return fs.existsSync(filePath); - }, - readFileSync: (filePath: string) => { - if (filePath === 'public/files/perm/themes/base/test-logo/config.xml') return customConfig; - return fs.readFileSync(filePath, 'utf-8'); - }, - readdirSync: fs.readdirSync, - }, - }); - app = new Elysia().use(themesRoutes); - - const res = await app.handle(new Request('http://localhost/api/themes/installed/test-logo')); - const body = await res.json(); - - expect(body.logoImg).toBe('logo.png'); - expect(body.logoImgUrl).toContain('/img/logo.png'); + // At least one theme should have style.css as fallback + const theme = body.themes.find((t: { cssFiles: string[] }) => t.cssFiles.includes('style.css')); + expect(theme).toBeDefined(); }); - it('should parse theme with header-img', async () => { - const customConfig = ` - - test-theme - Test Theme - 1.0 - header.jpg -`; - + it('should handle theme with no icons directory', async () => { configure({ fs: { - existsSync: (filePath: string) => { - if (filePath === 'public/files/perm/themes/base/test-header/config.xml') return true; - if (filePath === 'public/files/perm/themes/users/test-header/config.xml') return false; - return fs.existsSync(filePath); - }, - readFileSync: (filePath: string) => { - if (filePath === 'public/files/perm/themes/base/test-header/config.xml') return customConfig; - return fs.readFileSync(filePath, 'utf-8'); + existsSync: (p: string) => { + if (typeof p === 'string' && p.includes('/icons')) return false; + return fs.existsSync(p); }, + readFileSync: fs.readFileSync, readdirSync: fs.readdirSync, }, }); app = new Elysia().use(themesRoutes); - const res = await app.handle(new Request('http://localhost/api/themes/installed/test-header')); + const res = await app.handle(new Request('http://localhost/api/themes/installed')); const body = await res.json(); - expect(body.headerImg).toBe('header.jpg'); - expect(body.headerImgUrl).toContain('/img/header.jpg'); + expect(body.themes.length).toBeGreaterThan(0); + // Themes should have empty icons object + expect(typeof body.themes[0].icons).toBe('object'); }); - it('should parse theme with text-color', async () => { - const customConfig = ` - - test-theme - Test Theme - 1.0 - #333333 -`; - + it('should handle non-directory entries in themes folder', async () => { configure({ fs: { - existsSync: (filePath: string) => { - if (filePath === 'public/files/perm/themes/base/test-textcolor/config.xml') return true; - if (filePath === 'public/files/perm/themes/users/test-textcolor/config.xml') return false; - return fs.existsSync(filePath); - }, - readFileSync: (filePath: string) => { - if (filePath === 'public/files/perm/themes/base/test-textcolor/config.xml') return customConfig; - return fs.readFileSync(filePath, 'utf-8'); + existsSync: fs.existsSync, + readFileSync: fs.readFileSync, + readdirSync: (dirPath: string, options?: { withFileTypes: boolean }) => { + const entries = fs.readdirSync(dirPath, options); + if (typeof dirPath === 'string' && dirPath === 'public/files/perm/themes/base') { + // Add a fake file entry + if (Array.isArray(entries) && options?.withFileTypes) { + const fakeFile = { + name: 'not-a-directory.txt', + isDirectory: () => false, + isFile: () => true, + }; + return [...(entries as fs.Dirent[]), fakeFile as fs.Dirent]; + } + } + return entries; }, - readdirSync: fs.readdirSync, }, }); app = new Elysia().use(themesRoutes); - const res = await app.handle(new Request('http://localhost/api/themes/installed/test-textcolor')); + const res = await app.handle(new Request('http://localhost/api/themes/installed')); const body = await res.json(); - expect(body.textColor).toBe('#333333'); + // Should not crash and should return valid themes + expect(body.themes.length).toBeGreaterThan(0); }); - it('should parse theme with link-color', async () => { - const customConfig = ` - - test-theme - Test Theme - 1.0 - #0066cc -`; - + it('should handle hidden directories (starting with dot)', async () => { configure({ fs: { - existsSync: (filePath: string) => { - if (filePath === 'public/files/perm/themes/base/test-linkcolor/config.xml') return true; - if (filePath === 'public/files/perm/themes/users/test-linkcolor/config.xml') return false; - return fs.existsSync(filePath); - }, - readFileSync: (filePath: string) => { - if (filePath === 'public/files/perm/themes/base/test-linkcolor/config.xml') return customConfig; - return fs.readFileSync(filePath, 'utf-8'); + existsSync: fs.existsSync, + readFileSync: fs.readFileSync, + readdirSync: (dirPath: string, options?: { withFileTypes: boolean }) => { + const entries = fs.readdirSync(dirPath, options); + if (typeof dirPath === 'string' && dirPath === 'public/files/perm/themes/base') { + if (Array.isArray(entries) && options?.withFileTypes) { + const hiddenDir = { + name: '.hidden-theme', + isDirectory: () => true, + isFile: () => false, + }; + return [...(entries as fs.Dirent[]), hiddenDir as fs.Dirent]; + } + } + return entries; }, - readdirSync: fs.readdirSync, }, }); app = new Elysia().use(themesRoutes); - const res = await app.handle(new Request('http://localhost/api/themes/installed/test-linkcolor')); + const res = await app.handle(new Request('http://localhost/api/themes/installed')); const body = await res.json(); - expect(body.linkColor).toBe('#0066cc'); + // Should not include hidden directory + const hiddenTheme = body.themes.find((t: { dirName: string }) => t.dirName === '.hidden-theme'); + expect(hiddenTheme).toBeUndefined(); }); - }); - describe('parseThemeConfig error handling', () => { - it('should return 500 when config parsing throws exception', async () => { - // To trigger parseThemeConfig's catch block, we need to make something - // inside the try block throw. We can do this by making readFileSync - // inside parseThemeConfig throw (for scanning). + it('should return 500 when theme config parsing fails', async () => { + // Create a mock that makes parseThemeConfig throw and return null configure({ fs: { - existsSync: (filePath: string) => { - if (filePath === 'public/files/perm/themes/base/broken-theme/config.xml') return true; - if (filePath === 'public/files/perm/themes/users/broken-theme/config.xml') return false; - if (filePath.includes('broken-theme')) return true; - return fs.existsSync(filePath); - }, - readFileSync: (filePath: string) => { - if (filePath === 'public/files/perm/themes/base/broken-theme/config.xml') { - // Return valid config - the error will happen elsewhere - return `broken`; + existsSync: (p: string) => { + if (typeof p === 'string' && p.includes('malformed-theme')) { + return true; } - // Throw when trying to read package.json to get version - // This will propagate up since getAppVersion is called inside parseThemeConfig - if (filePath === 'package.json') { - // Create an object that throws when JSON.parse accesses it - return '{ invalid json that will throw }}}'; + return fs.existsSync(p); + }, + readFileSync: (p: string, encoding?: BufferEncoding) => { + if (typeof p === 'string' && p.includes('malformed-theme/config.xml')) { + return 'Test'; } - return fs.readFileSync(filePath, 'utf-8'); + return fs.readFileSync(p, encoding); }, - readdirSync: (dirPath: any, options?: any) => { - if (typeof dirPath === 'string' && dirPath.includes('broken-theme')) { - return []; + readdirSync: (dirPath: string, options?: { withFileTypes: boolean }) => { + // Make readdirSync throw for the theme directory (inside parseThemeConfig try block) + if (typeof dirPath === 'string' && dirPath.includes('malformed-theme')) { + throw new Error('Cannot read directory'); } return fs.readdirSync(dirPath, options); }, }, - getEnv: () => undefined, - }); - app = new Elysia().use(themesRoutes); - - const res = await app.handle(new Request('http://localhost/api/themes/installed/broken-theme')); - const body = await res.json(); - - // With invalid JSON, getAppVersion falls back to v0.0.0 - // The theme should still parse successfully - expect(res.status).toBe(200); - expect(body.name).toBe('broken'); - }); - }); - - describe('scanThemes with non-existent path', () => { - it('should return empty array when themes base path does not exist', async () => { - configure({ - fs: { - existsSync: (filePath: string) => { - // Both theme paths don't exist - if (filePath === 'public/files/perm/themes/base') return false; - if (filePath === 'public/files/perm/themes/users') return false; - return fs.existsSync(filePath); - }, - readFileSync: fs.readFileSync, - readdirSync: fs.readdirSync, - }, - }); - app = new Elysia().use(themesRoutes); - - const res = await app.handle(new Request('http://localhost/api/themes/installed')); - const body = await res.json(); - - expect(body.themes).toEqual([]); - }); - }); - - describe('POST /api/themes/import', () => { - it('should return 422 when no file uploaded (Elysia validation)', async () => { - const formData = new FormData(); - formData.append('themeDirname', 'test-theme'); - - const res = await app.handle( - new Request('http://localhost/api/themes/import', { - method: 'POST', - body: formData, - }), - ); - - // Elysia returns 422 for schema validation errors - expect(res.status).toBe(422); - }); - - it('should return 422 when no dirname provided (Elysia validation)', async () => { - const formData = new FormData(); - formData.append('themeZip', new Blob(['test']), 'test.zip'); - - const res = await app.handle( - new Request('http://localhost/api/themes/import', { - method: 'POST', - body: formData, - }), - ); - - // Elysia returns 422 for schema validation errors - expect(res.status).toBe(422); - }); - - it('should return error for invalid ZIP file', async () => { - configure({ - validateThemeZip: async () => ({ valid: false, error: 'Invalid ZIP format' }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const formData = new FormData(); - formData.append('themeZip', new Blob(['not a zip']), 'invalid.zip'); - formData.append('themeDirname', 'test-theme'); - - const res = await app.handle( - new Request('http://localhost/api/themes/import', { - method: 'POST', - body: formData, - }), - ); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.responseMessage).toBe('ERROR'); - expect(body.error).toBe('Invalid ZIP format'); - }); - - it('should return error when theme name conflicts with base theme', async () => { - configure({ - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'Test Theme', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, }); app = new Elysia().use(themesRoutes); - const formData = new FormData(); - formData.append('themeZip', new Blob(['valid zip']), 'theme.zip'); - formData.append('themeDirname', 'base'); // 'base' is a protected name - - const res = await app.handle( - new Request('http://localhost/api/themes/import', { - method: 'POST', - body: formData, - }), - ); + // Try to get the specific malformed theme + const res = await app.handle(new Request('http://localhost/api/themes/installed/malformed-theme')); - expect(res.status).toBe(400); + expect(res.status).toBe(500); const body = await res.json(); - expect(body.responseMessage).toBe('ERROR'); - expect(body.error).toContain('already exists on the server (base theme)'); + expect(body.error).toBe('Parse Error'); }); - it('should return error when theme exists in base directory', async () => { + it('should handle scanThemeFiles when path does not exist', async () => { + // Test the scanThemeFiles early return when path doesn't exist configure({ fs: { existsSync: (p: string) => { - if (p.includes('themes/base/new-theme')) return true; + // config.xml exists + if (typeof p === 'string' && p.includes('empty-theme/config.xml')) { + return true; + } + // Theme directory for scanning CSS/JS doesn't exist + if (typeof p === 'string' && p.includes('empty-theme') && !p.includes('config.xml')) { + return false; + } return fs.existsSync(p); }, - readFileSync: fs.readFileSync, - readdirSync: fs.readdirSync, - }, - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'New Theme', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const formData = new FormData(); - formData.append('themeZip', new Blob(['valid zip']), 'theme.zip'); - formData.append('themeDirname', 'new-theme'); - - const res = await app.handle( - new Request('http://localhost/api/themes/import', { - method: 'POST', - body: formData, - }), - ); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain('already exists on the server (base theme)'); - }); - - it('should return error when theme exists in site directory', async () => { - configure({ - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'Site Theme', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async (p: string) => { - if (p.includes('themes/site/site-theme')) return true; - return false; - }, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const formData = new FormData(); - formData.append('themeZip', new Blob(['valid zip']), 'theme.zip'); - formData.append('themeDirname', 'site-theme'); - - const res = await app.handle( - new Request('http://localhost/api/themes/import', { - method: 'POST', - body: formData, - }), - ); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain('already exists on the server (site theme)'); - }); - - it('should return success when theme already exists in user folder', async () => { - configure({ - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'User Theme', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async (p: string) => { - // Theme exists in user folder - if (p.includes('themes/users/user-theme')) return true; - return false; + readFileSync: (p: string, encoding?: BufferEncoding) => { + if (typeof p === 'string' && p.includes('empty-theme/config.xml')) { + return 'Empty Theme1.0'; + } + return fs.readFileSync(p, encoding); }, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const formData = new FormData(); - formData.append('themeZip', new Blob(['valid zip']), 'theme.zip'); - formData.append('themeDirname', 'user-theme'); - - const res = await app.handle( - new Request('http://localhost/api/themes/import', { - method: 'POST', - body: formData, - }), - ); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.responseMessage).toBe('OK'); - expect(body.themes).toBeDefined(); - }); - - it('should successfully import new theme', async () => { - let extractCalled = false; - configure({ - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'Brand New Theme', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => { - extractCalled = true; - }, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const formData = new FormData(); - formData.append('themeZip', new Blob(['valid zip']), 'theme.zip'); - formData.append('themeDirname', 'brand-new-theme'); - - const res = await app.handle( - new Request('http://localhost/api/themes/import', { - method: 'POST', - body: formData, - }), - ); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.responseMessage).toBe('OK'); - expect(extractCalled).toBe(true); - }); - - it('should handle extraction errors', async () => { - configure({ - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'Error Theme', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => { - throw new Error('Extraction failed'); - }, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const formData = new FormData(); - formData.append('themeZip', new Blob(['valid zip']), 'theme.zip'); - formData.append('themeDirname', 'error-theme'); - - const res = await app.handle( - new Request('http://localhost/api/themes/import', { - method: 'POST', - body: formData, - }), - ); - - expect(res.status).toBe(500); - const body = await res.json(); - expect(body.responseMessage).toBe('ERROR'); - expect(body.error).toBe('Extraction failed'); - }); - - it('should return error when dirname produces empty dirName after slugify', async () => { - configure({ - validateThemeZip: async () => ({ - valid: true, - metadata: { name: ' ', version: '1.0', author: 'Test' }, // Whitespace name - }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const formData = new FormData(); - formData.append('themeZip', new Blob(['valid zip']), 'theme.zip'); - formData.append('themeDirname', '!!!'); // Produces empty after slugify - - const res = await app.handle( - new Request('http://localhost/api/themes/import', { - method: 'POST', - body: formData, - }), - ); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain('Could not generate valid directory name'); - }); - - it('should scan user themes directory when it exists during re-import', async () => { - configure({ - fs: { - existsSync: (p: string) => { - // User themes dir exists - if (p.includes('themes/users')) return true; - return fs.existsSync(p); - }, - readFileSync: fs.readFileSync, - readdirSync: (p: any, opts?: any) => { - // Return empty dir for user themes - if (typeof p === 'string' && p.includes('themes/users')) return []; - return fs.readdirSync(p, opts); - }, - }, - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'Existing Theme', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async (p: string) => { - // Theme already exists in user folder - if (p.includes('themes/users/existing-theme')) return true; - // User themes dir exists - if (p.includes('themes/users')) return true; - return false; - }, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const formData = new FormData(); - formData.append('themeZip', new Blob(['valid zip']), 'theme.zip'); - formData.append('themeDirname', 'existing-theme'); - - const res = await app.handle( - new Request('http://localhost/api/themes/import', { - method: 'POST', - body: formData, - }), - ); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.responseMessage).toBe('OK'); - }); - }); - - describe('POST /api/themes/upload', () => { - it('should return 422 when file is missing (Elysia validation)', async () => { - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filename: 'test.zip' }), - }), - ); - - // Elysia returns 422 for schema validation errors - expect(res.status).toBe(422); - }); - - it('should return 422 when filename is missing (Elysia validation)', async () => { - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ file: 'base64data' }), - }), - ); - - // Elysia returns 422 for schema validation errors - expect(res.status).toBe(422); - }); - - it('should return error for invalid ZIP content', async () => { - configure({ - validateThemeZip: async () => ({ valid: false, error: 'Invalid theme ZIP format' }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const invalidZip = Buffer.from('not a zip file').toString('base64'); - - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: 'test.zip', - file: invalidZip, - }), - }), - ); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.responseMessage).toBe('ERROR'); - expect(body.error).toBe('Invalid theme ZIP format'); - }); - - it('should return error for invalid data URL ZIP', async () => { - configure({ - validateThemeZip: async () => ({ valid: false, error: 'Invalid ZIP' }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const invalidZip = Buffer.from('not a zip').toString('base64'); - - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: 'test.zip', - file: `data:application/zip;base64,${invalidZip}`, - }), - }), - ); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.responseMessage).toBe('ERROR'); - }); - - it('should return error when theme conflicts with base theme name', async () => { - configure({ - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'Base', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const zipData = Buffer.from('valid zip data').toString('base64'); - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: 'base.zip', - file: zipData, - }), - }), - ); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain('already exists on the server (base theme)'); - }); - - it('should return error when theme exists in base directory', async () => { - configure({ - fs: { - existsSync: (p: string) => { - if (p.includes('themes/base/existing-theme')) return true; - return fs.existsSync(p); - }, - readFileSync: fs.readFileSync, - readdirSync: fs.readdirSync, - }, - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'Existing Theme', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const zipData = Buffer.from('valid zip data').toString('base64'); - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: 'existing-theme.zip', - file: zipData, - }), - }), - ); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain('already exists on the server (base theme)'); - }); - - it('should return error when theme exists in site directory', async () => { - configure({ - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'Site Existing', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async (p: string) => { - if (p.includes('themes/site/site-existing')) return true; - return false; - }, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const zipData = Buffer.from('valid zip data').toString('base64'); - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: 'site-existing.zip', - file: zipData, - }), - }), - ); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain('already exists on the server (site theme)'); - }); - - it('should return error when theme exists in legacy user directory', async () => { - configure({ - fs: { - existsSync: (p: string) => { - if (p.includes('themes/users/legacy-theme')) return true; - return fs.existsSync(p); - }, - readFileSync: fs.readFileSync, - readdirSync: fs.readdirSync, - }, - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'Legacy Theme', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const zipData = Buffer.from('valid zip data').toString('base64'); - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: 'legacy-theme.zip', - file: zipData, - }), - }), - ); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain('already exists'); - }); - - it('should return error when theme exists in FILES_DIR user directory', async () => { - configure({ - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'User Dir Theme', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async (p: string) => { - if (p.includes('themes/users/user-dir-theme')) return true; - return false; - }, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const zipData = Buffer.from('valid zip data').toString('base64'); - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: 'user-dir-theme.zip', - file: zipData, - }), - }), - ); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain('already exists'); - }); - - it('should successfully upload and extract new theme with config.xml', async () => { - let extractCalled = false; - const configXml = ` -uploaded-themeUploaded Theme`; - - configure({ - fs: { - existsSync: (p: string) => { - if (p.includes('uploaded-theme/config.xml')) return true; - return fs.existsSync(p); - }, - readFileSync: (p: string, encoding?: string) => { - if (p.includes('uploaded-theme/config.xml')) return configXml; - return fs.readFileSync(p, encoding as BufferEncoding); - }, - readdirSync: fs.readdirSync, - }, - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'Uploaded Theme', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => { - extractCalled = true; - }, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const zipData = Buffer.from('valid zip data').toString('base64'); - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: 'uploaded-theme.zip', - file: zipData, - }), - }), - ); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.responseMessage).toBe('OK'); - expect(body.theme).toBeDefined(); - expect(extractCalled).toBe(true); - }); - - it('should successfully upload theme without config.xml', async () => { - configure({ - fs: { - existsSync: (p: string) => { - if (p.includes('no-config-theme/config.xml')) return false; - return fs.existsSync(p); - }, - readFileSync: fs.readFileSync, - readdirSync: fs.readdirSync, - }, - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'No Config Theme', version: '2.0', author: 'Author' }, - }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const zipData = Buffer.from('valid zip data').toString('base64'); - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: 'no-config-theme.zip', - file: zipData, - }), - }), - ); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.responseMessage).toBe('OK'); - expect(body.theme.displayName).toBe('No Config Theme'); - expect(body.theme.version).toBe('2.0'); - }); - - it('should fallback when config.xml parsing fails', async () => { - configure({ - fs: { - existsSync: (p: string) => { - if (p.includes('parse-fail-theme/config.xml')) return true; - return fs.existsSync(p); - }, - readFileSync: (p: string, encoding?: string) => { - if (p.includes('parse-fail-theme/config.xml')) return null as any; // Will cause parsing to fail - return fs.readFileSync(p, encoding as BufferEncoding); - }, - readdirSync: fs.readdirSync, - }, - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'Parse Fail Theme', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const zipData = Buffer.from('valid zip data').toString('base64'); - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: 'parse-fail-theme.zip', - file: zipData, - }), - }), - ); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.theme.displayName).toBe('Parse Fail Theme'); - }); - - it('should handle extraction errors', async () => { - configure({ - validateThemeZip: async () => ({ - valid: true, - metadata: { name: 'Error Theme', version: '1.0', author: 'Test' }, - }), - extractTheme: async () => { - throw new Error('Upload extraction failed'); - }, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const zipData = Buffer.from('valid zip data').toString('base64'); - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: 'error-theme.zip', - file: zipData, - }), - }), - ); - - expect(res.status).toBe(500); - const body = await res.json(); - expect(body.responseMessage).toBe('ERROR'); - expect(body.error).toBe('Upload extraction failed'); - }); - - it('should return error when data URL has empty base64 part', async () => { - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: 'test.zip', - file: 'data:application/zip;base64,', // Empty base64 after comma - }), - }), - ); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toBe('Invalid base64 data'); - }); - - it('should return error when filename produces empty dirName after slugify', async () => { - configure({ - validateThemeZip: async () => ({ - valid: true, - metadata: { name: ' ', version: '1.0', author: 'Test' }, // Whitespace-only name - }), - extractTheme: async () => {}, - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const zipData = Buffer.from('valid zip data').toString('base64'); - const res = await app.handle( - new Request('http://localhost/api/themes/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: '!!!.zip', // Will produce empty after slugify - file: zipData, - }), - }), - ); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain('Could not generate valid directory name'); - }); - }); - - describe('DELETE /api/themes/:themeId/delete', () => { - it('should return error when no theme ID provided', async () => { - const res = await app.handle( - new Request('http://localhost/api/themes/{themeId}/delete', { - method: 'DELETE', - }), - ); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.responseMessage).toBe('ERROR'); - expect(body.error).toBe('No theme ID provided'); - }); - - it('should return 403 when trying to delete base theme', async () => { - const res = await app.handle( - new Request('http://localhost/api/themes/base/delete', { - method: 'DELETE', - }), - ); - - expect(res.status).toBe(403); - const body = await res.json(); - expect(body.responseMessage).toBe('ERROR'); - expect(body.error).toBe('Cannot delete built-in themes'); - }); - - it('should return 404 when theme does not exist', async () => { - configure({ - fsExtra: { - pathExists: async () => false, - remove: async () => {}, - }, - }); - app = new Elysia().use(themesRoutes); - - const res = await app.handle( - new Request('http://localhost/api/themes/non-existent-user-theme/delete', { - method: 'DELETE', - }), - ); - - expect(res.status).toBe(404); - const body = await res.json(); - expect(body.responseMessage).toBe('ERROR'); - expect(body.error).toContain('not found'); - }); - - it('should handle theme ID from body when path param is placeholder', async () => { - const res = await app.handle( - new Request('http://localhost/api/themes/{themeId}/delete', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: 'base' }), - }), - ); - - // Should recognize 'base' as a protected theme - expect(res.status).toBe(403); - const body = await res.json(); - expect(body.error).toBe('Cannot delete built-in themes'); - }); - - it('should delete theme from legacy location', async () => { - let removeCalled = false; - configure({ - fs: { - existsSync: (p: string) => { - if (p.includes('themes/users/legacy-deletable')) return true; - return fs.existsSync(p); - }, - readFileSync: fs.readFileSync, - readdirSync: fs.readdirSync, - }, - fsExtra: { - pathExists: async () => false, // Not in user themes dir - remove: async () => { - removeCalled = true; - }, - }, - }); - app = new Elysia().use(themesRoutes); - - const res = await app.handle( - new Request('http://localhost/api/themes/legacy-deletable/delete', { - method: 'DELETE', - }), - ); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.responseMessage).toBe('OK'); - expect(body.deleted.name).toBe('legacy-deletable'); - expect(removeCalled).toBe(true); - }); - - it('should delete theme from user themes directory', async () => { - let removeCalled = false; - configure({ - fsExtra: { - pathExists: async (p: string) => { - if (p.includes('themes/users/user-deletable')) return true; - return false; - }, - remove: async () => { - removeCalled = true; - }, - }, - }); - app = new Elysia().use(themesRoutes); - - const res = await app.handle( - new Request('http://localhost/api/themes/user-deletable/delete', { - method: 'DELETE', - }), - ); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.responseMessage).toBe('OK'); - expect(body.deleted.name).toBe('user-deletable'); - expect(removeCalled).toBe(true); - }); - - it('should handle deletion errors', async () => { - configure({ - fsExtra: { - pathExists: async () => true, - remove: async () => { - throw new Error('Permission denied'); - }, - }, - }); - app = new Elysia().use(themesRoutes); - - const res = await app.handle( - new Request('http://localhost/api/themes/error-delete-theme/delete', { - method: 'DELETE', - }), - ); - - expect(res.status).toBe(500); - const body = await res.json(); - expect(body.responseMessage).toBe('ERROR'); - expect(body.error).toBe('Permission denied'); - }); - }); - - describe('parseThemeConfig catch block', () => { - it('should return null when config parsing throws', async () => { - configure({ - fs: { - existsSync: (filePath: string) => { - if (filePath.includes('parse-error-theme')) return true; - return fs.existsSync(filePath); - }, - readFileSync: (filePath: string) => { - if (filePath.includes('parse-error-theme/config.xml')) { - // Return XML that will cause parsing to throw - // Actually need to trigger an error in the try block - // The getValue function uses regex, so we need something else to throw - return null as any; // This will cause .match() to throw - } - return fs.readFileSync(filePath, 'utf-8'); - }, - readdirSync: fs.readdirSync, - }, - }); - app = new Elysia().use(themesRoutes); - - const res = await app.handle(new Request('http://localhost/api/themes/installed/parse-error-theme')); - - // Should return 500 since parseThemeConfig returns null - expect(res.status).toBe(500); - const body = await res.json(); - expect(body.error).toBe('Parse Error'); - }); - }); - - describe('customUrlPrefix in parseThemeConfig', () => { - it('should use custom URL prefix for site themes', async () => { - // This tests line 190 - customUrlPrefix branch - configure({ - fs: { - existsSync: (filePath: string) => { - if (filePath.includes('site-theme-test')) return true; - return fs.existsSync(filePath); - }, - readFileSync: (filePath: string) => { - if (filePath.includes('site-theme-test/config.xml')) { - return ` - - site-theme-test - Site Theme Test -`; - } - return fs.readFileSync(filePath, 'utf-8'); - }, - readdirSync: (dirPath: any, options?: any) => { - if (typeof dirPath === 'string' && dirPath.includes('site-theme-test')) { - return []; - } - return fs.readdirSync(dirPath, options); - }, - }, - }); - app = new Elysia().use(themesRoutes); - - // The /api/themes/installed endpoint uses custom prefix for user themes from FILES_DIR - const res = await app.handle(new Request('http://localhost/api/themes/installed')); - const body = await res.json(); - - // Verify theme list is returned - expect(body.themes).toBeDefined(); - }); - }); - - describe('icon file type detection', () => { - it('should detect various icon file types', async () => { - configure({ - fs: { - existsSync: (filePath: string) => { - if (filePath.includes('icon-test-theme')) return true; - return fs.existsSync(filePath); - }, - readFileSync: (filePath: string) => { - if (filePath.includes('icon-test-theme/config.xml')) { - return `icon-test`; - } - return fs.readFileSync(filePath, 'utf-8'); - }, - readdirSync: (dirPath: any, options?: any) => { - if (typeof dirPath === 'string' && dirPath.includes('icon-test-theme/icons')) { + readdirSync: (dirPath: string, options?: { withFileTypes: boolean }) => { + if (typeof dirPath === 'string' && dirPath === 'public/files/perm/themes/base') { return [ - { name: 'icon1.png', isFile: () => true, isDirectory: () => false }, - { name: 'icon2.svg', isFile: () => true, isDirectory: () => false }, - { name: 'icon3.gif', isFile: () => true, isDirectory: () => false }, - { name: 'icon4.jpg', isFile: () => true, isDirectory: () => false }, - { name: 'icon5.jpeg', isFile: () => true, isDirectory: () => false }, - { name: 'noticon.txt', isFile: () => true, isDirectory: () => false }, - ]; - } - if (typeof dirPath === 'string' && dirPath.includes('icon-test-theme')) { - return []; + { + name: 'empty-theme', + isDirectory: () => true, + isFile: () => false, + }, + ] as fs.Dirent[]; } - return fs.readdirSync(dirPath, options); - }, - }, - }); - app = new Elysia().use(themesRoutes); - - const res = await app.handle(new Request('http://localhost/api/themes/installed/icon-test-theme')); - const body = await res.json(); - - expect(body.icons).toBeDefined(); - expect(body.icons['icon1']).toBeDefined(); - expect(body.icons['icon2']).toBeDefined(); - expect(body.icons['icon3']).toBeDefined(); - expect(body.icons['icon4']).toBeDefined(); - expect(body.icons['icon5']).toBeDefined(); - expect(body.icons['noticon']).toBeUndefined(); - }); - }); - - describe('theme type handling', () => { - it('should mark site theme as default when matching', async () => { - // This is hard to test without mocking the database - // The test verifies the themes endpoint works correctly - const res = await app.handle(new Request('http://localhost/api/themes/installed')); - const body = await res.json(); - - // At least one theme should be returned - expect(body.themes.length).toBeGreaterThan(0); - expect(body.defaultTheme).toBeDefined(); - }); - }); - - describe('directory entry handling', () => { - it('should skip hidden directories and non-directories', async () => { - configure({ - fs: { - existsSync: fs.existsSync, - readFileSync: fs.readFileSync, - readdirSync: (dirPath: any, options?: any) => { - if (dirPath === 'public/files/perm/themes/base') { - return [ - { name: '.hidden', isDirectory: () => true }, - { name: 'regular-file.txt', isDirectory: () => false }, - { name: 'base', isDirectory: () => true }, - ]; + // For theme directory scanning, return empty + if (typeof dirPath === 'string' && dirPath.includes('empty-theme')) { + return [] as fs.Dirent[]; } return fs.readdirSync(dirPath, options); }, @@ -1681,10 +475,10 @@ describe('Themes Routes', () => { const res = await app.handle(new Request('http://localhost/api/themes/installed')); const body = await res.json(); - // Should only include 'base' theme, not hidden or files - const dirNames = body.themes.map((t: any) => t.dirName); - expect(dirNames).not.toContain('.hidden'); - expect(dirNames).not.toContain('regular-file.txt'); + const emptyTheme = body.themes.find((t: { dirName: string }) => t.dirName === 'empty-theme'); + expect(emptyTheme).toBeDefined(); + // Should fall back to style.css when no CSS files found + expect(emptyTheme?.cssFiles).toContain('style.css'); }); }); }); diff --git a/src/routes/themes.ts b/src/routes/themes.ts index 7aaf2b1a6..48258794f 100644 --- a/src/routes/themes.ts +++ b/src/routes/themes.ts @@ -1,21 +1,20 @@ /** * Themes Routes for Elysia - * Handles installed themes listing and management + * Handles installed themes listing (base and site themes only) * - * Ported from NestJS ThemeService to match frontend expectations + * User themes imported from .elpx files are stored client-side in Yjs, + * not on the server. This simplifies the architecture and allows + * themes to sync automatically between collaborators. */ -import { Elysia, t } from 'elysia'; +import { Elysia } from 'elysia'; import * as fs from 'fs'; -import * as fsExtra from 'fs-extra'; import * as path from 'path'; import { db } from '../db/client'; import { getEnabledSiteThemes, getDefaultTheme, getBaseThemes } from '../db/queries/themes'; import type { Theme } from '../db/types'; -import { validateThemeZip, extractTheme, slugify, BASE_THEME_NAMES } from '../services/admin-upload-validator'; -// Base path for themes +// Base path for themes (bundled with the app) const THEMES_BASE_PATH = 'public/files/perm/themes/base'; -const THEMES_USERS_PATH = 'public/files/perm/themes/users'; // Get site themes directory (admin-uploaded themes) const getSiteThemesPath = () => { @@ -32,13 +31,7 @@ export interface ThemesRouteDependencies { readFileSync: typeof fs.readFileSync; readdirSync: typeof fs.readdirSync; }; - fsExtra: { - pathExists: typeof fsExtra.pathExists; - remove: typeof fsExtra.remove; - }; getEnv: (key: string) => string | undefined; - validateThemeZip: typeof validateThemeZip; - extractTheme: typeof extractTheme; } const defaultDeps: ThemesRouteDependencies = { @@ -47,13 +40,7 @@ const defaultDeps: ThemesRouteDependencies = { readFileSync: fs.readFileSync, readdirSync: fs.readdirSync, }, - fsExtra: { - pathExists: fsExtra.pathExists, - remove: fsExtra.remove, - }, getEnv: (key: string) => process.env[key], - validateThemeZip, - extractTheme, }; let deps = defaultDeps; @@ -73,28 +60,30 @@ const getAppVersion = (): string => { return envVersion; } try { - const packageJson = JSON.parse(deps.fs.readFileSync('package.json', 'utf-8')); - return `v${packageJson.version}`; + const packageJsonPath = path.join(process.cwd(), 'package.json'); + if (deps.fs.existsSync(packageJsonPath)) { + const content = deps.fs.readFileSync(packageJsonPath, 'utf-8'); + const pkg = JSON.parse(content); + return `v${pkg.version || '0.0.0'}`; + } } catch { - return 'v0.0.0'; + // Ignore parse errors } + return 'v0.0.0'; }; -interface ThemeIcon { - id: string; - title: string; - type: string; - value: string; -} - +/** + * Theme configuration interface + */ interface ThemeConfig { + id?: string; name: string; dirName: string; displayName: string; title: string; url: string; preview: string; - type: 'base' | 'user' | 'site'; + type: 'base' | 'site'; version: string; compatibility: string; author: string; @@ -104,85 +93,58 @@ interface ThemeConfig { downloadable: string; cssFiles: string[]; js: string[]; - icons: Record; - logoImg?: string; - logoImgUrl?: string; - headerImg?: string; - headerImgUrl?: string; - textColor?: string; - linkColor?: string; + icons: Record; valid: boolean; isDefault?: boolean; } /** - * Scan theme directory for files with specific extension + * Scan theme directory for CSS files */ function scanThemeFiles(themePath: string, extension: string): string[] { - try { - const files: string[] = []; - if (!deps.fs.existsSync(themePath)) return files; + const files: string[] = []; + if (!deps.fs.existsSync(themePath)) { + return files; + } - const entries = deps.fs.readdirSync(themePath, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isFile() && entry.name.endsWith(extension)) { - files.push(entry.name); - } + const entries = deps.fs.readdirSync(themePath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(extension)) { + files.push(entry.name); } - return files; - } catch { - return []; } + return files; } /** * Scan theme directory for icon files */ -function scanThemeIcons(themePath: string, themeUrl: string): Record { - try { - const iconsPath = path.join(themePath, 'icons'); - if (!deps.fs.existsSync(iconsPath)) return {}; - - const entries = deps.fs.readdirSync(iconsPath, { withFileTypes: true }); - const icons: Record = {}; - - for (const entry of entries) { - if ( - entry.isFile() && - (entry.name.endsWith('.png') || - entry.name.endsWith('.svg') || - entry.name.endsWith('.gif') || - entry.name.endsWith('.jpg') || - entry.name.endsWith('.jpeg')) - ) { - const iconId = path.basename(entry.name, path.extname(entry.name)); - icons[iconId] = { - id: iconId, - title: iconId, - type: 'img', - value: `${themeUrl}/icons/${entry.name}`, - }; - } - } +function scanThemeIcons(themePath: string, themeUrl: string): Record { + const icons: Record = {}; + const iconsPath = path.join(themePath, 'icons'); + + if (!deps.fs.existsSync(iconsPath)) { return icons; - } catch { - return {}; } + + const entries = deps.fs.readdirSync(iconsPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && (entry.name.endsWith('.png') || entry.name.endsWith('.svg'))) { + const iconName = entry.name.replace(/\.(png|svg)$/, ''); + icons[iconName] = `${themeUrl}/icons/${entry.name}`; + } + } + return icons; } /** * Parse theme config.xml - * @param xmlContent - XML content to parse - * @param themeId - Theme directory name - * @param themePath - Full path to theme directory - * @param type - Theme type (base/user) - * @param customUrlPrefix - Optional custom URL prefix for themes stored in FILES_DIR */ function parseThemeConfig( xmlContent: string, themeId: string, themePath: string, - type: 'base' | 'user', + type: 'base' | 'site', customUrlPrefix?: string, ): ThemeConfig | null { try { @@ -195,16 +157,11 @@ function parseThemeConfig( const version = getAppVersion(); // Build URL paths with version for cache busting - // NOTE: basePath is NOT included here because frontend adds it via symfonyURL in theme.js let themeBasePath: string; if (customUrlPrefix) { - // Use custom prefix for themes from FILES_DIR themeBasePath = `/${version}${customUrlPrefix}/${themeId}`; } else { - themeBasePath = - type === 'base' - ? `/${version}/files/perm/themes/base/${themeId}` - : `/${version}/files/perm/themes/users/${themeId}`; + themeBasePath = `/${version}/files/perm/themes/base/${themeId}`; } const previewPath = @@ -222,53 +179,26 @@ function parseThemeConfig( // Scan for icons const icons = scanThemeIcons(themePath, themeBasePath); - // Build theme config matching NestJS format - const config: ThemeConfig = { + return { name: getValue('name') || themeId, dirName: themeId, - displayName: getValue('title') || getValue('name') || themeId, - title: getValue('title') || getValue('name') || themeId, + displayName: getValue('name') || themeId, + title: getValue('name') || themeId, url: themeBasePath, preview: previewPath, - type: type, + type, version: getValue('version') || '1.0', - compatibility: getValue('compatibility') || '3.0', + compatibility: getValue('exe-version') || '3.0', author: getValue('author') || '', license: getValue('license') || '', licenseUrl: getValue('license-url') || '', description: getValue('description') || '', - downloadable: getValue('downloadable') || '0', + downloadable: getValue('downloadable') || '1', cssFiles, js, icons, valid: true, }; - - // Parse logo and header images - const logoImg = getValue('logo-img'); - if (logoImg) { - config.logoImg = logoImg; - config.logoImgUrl = `${themeBasePath}/img/${logoImg}`; - } - - const headerImg = getValue('header-img'); - if (headerImg) { - config.headerImg = headerImg; - config.headerImgUrl = `${themeBasePath}/img/${headerImg}`; - } - - // Parse color configuration - const textColor = getValue('text-color'); - if (textColor) { - config.textColor = textColor; - } - - const linkColor = getValue('link-color'); - if (linkColor) { - config.linkColor = linkColor; - } - - return config; } catch { return null; } @@ -276,11 +206,8 @@ function parseThemeConfig( /** * Scan themes directory and return list - * @param basePath - Directory path to scan for themes - * @param type - Theme type (base/user) - * @param customUrlPrefix - Optional custom URL prefix for themes served from non-standard location */ -function scanThemes(basePath: string, type: 'base' | 'user', customUrlPrefix?: string): ThemeConfig[] { +function scanThemes(basePath: string, type: 'base' | 'site', customUrlPrefix?: string): ThemeConfig[] { const themes: ThemeConfig[] = []; if (!deps.fs.existsSync(basePath)) { @@ -351,17 +278,15 @@ function siteThemeToConfig(siteTheme: Theme): ThemeConfig { }; } -// Get files directory -const getFilesDir = () => process.env.ELYSIA_FILES_DIR || process.env.FILES_DIR || '/mnt/data'; - /** * Themes routes + * Only serves base themes (bundled) and site themes (admin-uploaded). + * User themes from .elpx files are stored client-side in Yjs. */ export const themesRoutes = new Elysia({ name: 'themes-routes' }) - // GET /api/themes/installed - Get list of installed themes + // GET /api/themes/installed - Get list of installed themes (base + site) .get('/api/themes/installed', async () => { const baseThemes = scanThemes(THEMES_BASE_PATH, 'base'); - const userThemes = scanThemes(THEMES_USERS_PATH, 'user'); // Get enabled site themes from database let siteThemes: ThemeConfig[] = []; @@ -390,17 +315,8 @@ export const themesRoutes = new Elysia({ name: 'themes-routes' }) // Filter out disabled base themes const enabledBaseThemes = baseThemes.filter(t => !disabledBaseThemes.has(t.dirName)); - // Also scan user themes from FILES_DIR (imported from ELP files) - // These themes are served via /user-files/themes/ route - const userThemesDir = path.join(getFilesDir(), 'themes', 'users'); - let additionalUserThemes: ThemeConfig[] = []; - if (deps.fs.existsSync(userThemesDir)) { - // Pass custom URL prefix so preview images and assets use /user-files/themes/ route - additionalUserThemes = scanThemes(userThemesDir, 'user', '/user-files/themes'); - } - - // Combine all themes (base + user from public + user from FILES_DIR + site) - const allThemes = [...enabledBaseThemes, ...userThemes, ...additionalUserThemes, ...siteThemes]; + // Combine base + site themes + const allThemes = [...enabledBaseThemes, ...siteThemes]; // Mark the default theme for (const theme of allThemes) { @@ -429,16 +345,17 @@ export const themesRoutes = new Elysia({ name: 'themes-routes' }) .get('/api/themes/installed/:themeId', ({ params, set }) => { const { themeId } = params; - // Check user themes first - let configPath = path.join(THEMES_USERS_PATH, themeId, 'config.xml'); - let themePath = path.join(THEMES_USERS_PATH, themeId); - let type: 'base' | 'user' = 'user'; + // Check base themes first + let configPath = path.join(THEMES_BASE_PATH, themeId, 'config.xml'); + let themePath = path.join(THEMES_BASE_PATH, themeId); + let type: 'base' | 'site' = 'base'; if (!deps.fs.existsSync(configPath)) { - // Fall back to base themes - configPath = path.join(THEMES_BASE_PATH, themeId, 'config.xml'); - themePath = path.join(THEMES_BASE_PATH, themeId); - type = 'base'; + // Check site themes + const siteThemesPath = getSiteThemesPath(); + configPath = path.join(siteThemesPath, themeId, 'config.xml'); + themePath = path.join(siteThemesPath, themeId); + type = 'site'; } if (!deps.fs.existsSync(configPath)) { @@ -447,7 +364,8 @@ export const themesRoutes = new Elysia({ name: 'themes-routes' }) } const xmlContent = deps.fs.readFileSync(configPath, 'utf-8'); - const config = parseThemeConfig(xmlContent, themeId, themePath, type); + const customUrlPrefix = type === 'site' ? '/site-files/themes' : undefined; + const config = parseThemeConfig(xmlContent, themeId, themePath, type, customUrlPrefix); if (!config) { set.status = 500; @@ -457,394 +375,52 @@ export const themesRoutes = new Elysia({ name: 'themes-routes' }) return config; }) - /** - * POST /api/themes/import - Import theme from ELP file - * Allows users to install a theme from their .elpx file. - * - * SECURITY NOTE: Custom themes can contain JavaScript code that will be - * executed in the exported content context. This feature is controlled - * by the ONLINE_THEMES_INSTALL setting. The client-side code checks - * this setting before offering to import themes. Administrators should - * be aware that enabling ONLINE_THEMES_INSTALL allows users to run - * custom JavaScript in exported content. - */ - .post( - '/api/themes/import', - async ({ body, set, jwt, cookie }) => { - try { - const { themeZip, themeDirname } = body; - - if (!themeZip) { - set.status = 400; - return { responseMessage: 'ERROR', error: 'No theme file uploaded' }; - } - - if (!themeDirname) { - set.status = 400; - return { responseMessage: 'ERROR', error: 'No theme directory name provided' }; - } - - // Get file buffer - const fileBuffer = Buffer.from(await themeZip.arrayBuffer()); - - // Validate ZIP file - const validation = await deps.validateThemeZip(fileBuffer); - if (!validation.valid) { - set.status = 400; - return { responseMessage: 'ERROR', error: validation.error }; - } - - // Use provided dirname or generate from metadata - const dirName = slugify(themeDirname) || slugify(validation.metadata!.name); - if (!dirName) { - set.status = 400; - return { - responseMessage: 'ERROR', - error: 'Could not generate valid directory name for theme', - }; - } - - // Check if theme already exists in base or admin (conflicts not allowed) - // 1. Check base themes list - if (BASE_THEME_NAMES.includes(dirName.toLowerCase())) { - set.status = 400; - return { - responseMessage: 'ERROR', - error: `A theme with the name "${dirName}" already exists on the server (base theme)`, - }; - } - - // 2. Check base themes directory - const baseThemePath = path.join(THEMES_BASE_PATH, dirName); - if (deps.fs.existsSync(baseThemePath)) { - set.status = 400; - return { - responseMessage: 'ERROR', - error: `A theme with the name "${dirName}" already exists on the server (base theme)`, - }; - } - - // 3. Check site themes directory - const siteThemePath = path.join(getSiteThemesPath(), dirName); - if (await deps.fsExtra.pathExists(siteThemePath)) { - set.status = 400; - return { - responseMessage: 'ERROR', - error: `A theme with the name "${dirName}" already exists on the server (site theme)`, - }; - } - - // Themes imported from ELP files ALWAYS go to user themes folder - // Admin themes are only installed via the admin panel, not via ELP import - // This applies to all users, including admins - const targetDir = path.join(getFilesDir(), 'themes', 'users', dirName); - - // Check if theme already exists in user folder - if so, just return success - // (user can re-import a theme they already have) - if (await deps.fsExtra.pathExists(targetDir)) { - // Theme already exists, no need to import again - // Just return success with current theme list - const baseThemes = scanThemes(THEMES_BASE_PATH, 'base'); - const userThemes = scanThemes(THEMES_USERS_PATH, 'user'); - - let siteThemes: ThemeConfig[] = []; - try { - const siteThemesDb = await getEnabledSiteThemes(db); - siteThemes = siteThemesDb.map(siteThemeToConfig); - } catch { - // Ignore if table doesn't exist - } - - const userThemesDir = path.join(getFilesDir(), 'themes', 'users'); - let additionalUserThemes: ThemeConfig[] = []; - if (await deps.fsExtra.pathExists(userThemesDir)) { - additionalUserThemes = scanThemes(userThemesDir, 'user', '/user-files/themes'); - } - - const allThemes = [...baseThemes, ...userThemes, ...additionalUserThemes, ...siteThemes]; - allThemes.sort((a, b) => a.displayName.localeCompare(b.displayName)); - - return { - responseMessage: 'OK', - themes: { themes: allThemes }, - }; - } - - // Extract theme to user themes folder - await deps.extractTheme(fileBuffer, targetDir); - - // Return updated theme list - const baseThemes = scanThemes(THEMES_BASE_PATH, 'base'); - const userThemes = scanThemes(THEMES_USERS_PATH, 'user'); - - // Get enabled site themes from database - let siteThemes: ThemeConfig[] = []; - try { - const siteThemesDb = await getEnabledSiteThemes(db); - siteThemes = siteThemesDb.map(siteThemeToConfig); - } catch { - // Ignore if table doesn't exist - } - - // Also scan user themes from FILES_DIR (served via /user-files/themes/ route) - const userThemesDir = path.join(getFilesDir(), 'themes', 'users'); - let additionalUserThemes: ThemeConfig[] = []; - if (await deps.fsExtra.pathExists(userThemesDir)) { - additionalUserThemes = scanThemes(userThemesDir, 'user', '/user-files/themes'); - } - - const allThemes = [...baseThemes, ...userThemes, ...additionalUserThemes, ...siteThemes]; - allThemes.sort((a, b) => a.displayName.localeCompare(b.displayName)); - - return { - responseMessage: 'OK', - themes: { - themes: allThemes, - }, - }; - } catch (error) { - console.error('[themes] Theme import error:', error); - set.status = 500; - const message = error instanceof Error ? error.message : 'Unknown error'; - return { responseMessage: 'ERROR', error: message }; - } - }, - { - body: t.Object({ - themeZip: t.File(), - themeDirname: t.String(), - }), - }, - ) - - /** - * POST /api/themes/upload - Upload theme ZIP file (base64 encoded) - * Used by the workarea styles panel "Import style" button. - * - * SECURITY NOTE: Custom themes can contain JavaScript code that will be - * executed in the exported content context. This feature is controlled - * by the ONLINE_THEMES_INSTALL setting. Administrators should be aware - * that enabling ONLINE_THEMES_INSTALL allows users to run custom - * JavaScript in exported content. - */ - .post( - '/api/themes/upload', - async ({ body, set }) => { - try { - const { filename, file } = body; - - if (!file || !filename) { - set.status = 400; - return { responseMessage: 'ERROR', error: 'Missing file or filename' }; - } - - // Parse base64 data URL - let fileBuffer: Buffer; - if (file.startsWith('data:')) { - // Extract base64 part from data URL - const base64Data = file.split(',')[1]; - if (!base64Data) { - set.status = 400; - return { responseMessage: 'ERROR', error: 'Invalid base64 data' }; - } - fileBuffer = Buffer.from(base64Data, 'base64'); - } else { - // Assume raw base64 - fileBuffer = Buffer.from(file, 'base64'); - } - - // Validate ZIP file - const validation = await deps.validateThemeZip(fileBuffer); - if (!validation.valid) { - set.status = 400; - return { responseMessage: 'ERROR', error: validation.error }; - } + // GET /api/resources/theme/:themeName/bundle - Get theme files as a bundle for export + // This endpoint serves base and site themes for the exporter + .get('/api/resources/theme/:themeName/bundle', async ({ params, set }) => { + const { themeName } = params; - // Generate directory name from filename or config - const baseName = filename.replace(/\.zip$/i, ''); - const dirName = slugify(baseName) || slugify(validation.metadata!.name); - if (!dirName) { - set.status = 400; - return { - responseMessage: 'ERROR', - error: 'Could not generate valid directory name for theme', - }; - } - - // Check if theme already exists in any location (base, admin, user) - // 1. Check base themes - if (BASE_THEME_NAMES.includes(dirName.toLowerCase())) { - set.status = 400; - return { - responseMessage: 'ERROR', - error: `A theme with the name "${dirName}" already exists on the server (base theme)`, - }; - } + // Check base themes first + let themePath = path.join(THEMES_BASE_PATH, themeName); + let found = deps.fs.existsSync(themePath); - // 2. Check base themes directory - const baseThemePath = path.join(THEMES_BASE_PATH, dirName); - if (deps.fs.existsSync(baseThemePath)) { - set.status = 400; - return { - responseMessage: 'ERROR', - error: `A theme with the name "${dirName}" already exists on the server (base theme)`, - }; - } - - // 3. Check site themes directory - const siteThemePath = path.join(getSiteThemesPath(), dirName); - if (await deps.fsExtra.pathExists(siteThemePath)) { - set.status = 400; - return { - responseMessage: 'ERROR', - error: `A theme with the name "${dirName}" already exists on the server (site theme)`, - }; - } + if (!found) { + // Check site themes + const siteThemesPath = getSiteThemesPath(); + themePath = path.join(siteThemesPath, themeName); + found = deps.fs.existsSync(themePath); + } - // 4. Check legacy user themes directory - const legacyUserThemePath = path.join(THEMES_USERS_PATH, dirName); - if (deps.fs.existsSync(legacyUserThemePath)) { - set.status = 400; - return { - responseMessage: 'ERROR', - error: `A theme with the name "${dirName}" already exists`, - }; - } + if (!found) { + set.status = 404; + return { error: 'Not Found', message: `Theme ${themeName} not found` }; + } - // 5. Check user themes in FILES_DIR - const targetDir = path.join(getFilesDir(), 'themes', 'users', dirName); - if (await deps.fsExtra.pathExists(targetDir)) { - set.status = 400; - return { - responseMessage: 'ERROR', - error: `A theme with the name "${dirName}" already exists`, - }; - } + // Collect all files in the theme directory + const files: Record = {}; - // Extract theme to target directory - await deps.extractTheme(fileBuffer, targetDir); - - // Read config.xml to get theme metadata - const configPath = path.join(targetDir, 'config.xml'); - let themeConfig: ThemeConfig; - - if (deps.fs.existsSync(configPath)) { - const xmlContent = deps.fs.readFileSync(configPath, 'utf8'); - // Use /user-files/themes/ prefix for user themes from FILES_DIR - const parsed = parseThemeConfig(xmlContent, dirName, targetDir, 'user', '/user-files/themes'); - if (parsed) { - themeConfig = parsed; - } else { - // Fallback if config parse fails - const version = getAppVersion(); - themeConfig = { - id: dirName, - dirName: dirName, - displayName: validation.metadata!.name, - version: validation.metadata!.version || '1.0', - author: validation.metadata!.author || '', - type: 'user', - url: `/${version}/user-files/themes/${dirName}`, - preview: `/${version}/user-files/themes/${dirName}/preview.png`, - } as ThemeConfig; - } - } else { - // No config.xml, use validation metadata - const version = getAppVersion(); - themeConfig = { - id: dirName, - dirName: dirName, - displayName: validation.metadata!.name, - version: validation.metadata!.version || '1.0', - author: validation.metadata!.author || '', - type: 'user', - url: `/${version}/user-files/themes/${dirName}`, - preview: `/${version}/user-files/themes/${dirName}/preview.png`, - } as ThemeConfig; - } + function scanDir(dirPath: string, prefix = ''): void { + if (!deps.fs.existsSync(dirPath)) return; - return { - responseMessage: 'OK', - theme: themeConfig, - }; - } catch (error) { - console.error('[Themes] Upload error:', error); - set.status = 500; - return { - responseMessage: 'ERROR', - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - }, - { - body: t.Object({ - filename: t.String(), - file: t.String(), // base64 encoded file - }), - }, - ) - - /** - * DELETE /api/themes/:themeId/delete - Delete a user theme - * Only user-installed themes can be deleted, not base themes. - */ - .delete('/api/themes/:themeId/delete', async ({ params, body, set }) => { - try { - // Get theme ID from path param or body - // The client may send {themeId} literally if URL wasn't properly templated - let themeId = params.themeId; - if (themeId === '{themeId}' && body && typeof body === 'object' && 'id' in body) { - themeId = (body as { id: string }).id; - } + const entries = deps.fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; - if (!themeId || themeId === '{themeId}') { - set.status = 400; - return { responseMessage: 'ERROR', error: 'No theme ID provided' }; - } - - // Security: only allow deleting user themes, not base themes - if (BASE_THEME_NAMES.includes(themeId.toLowerCase())) { - set.status = 403; - return { - responseMessage: 'ERROR', - error: 'Cannot delete built-in themes', - }; - } - - // Check in user themes directory - const userThemePath = path.join(getFilesDir(), 'themes', 'users', themeId); - - if (!(await deps.fsExtra.pathExists(userThemePath))) { - // Also check in public/files/perm/themes/users (legacy location) - const legacyPath = path.join('public/files/perm/themes/users', themeId); - if (deps.fs.existsSync(legacyPath)) { - await deps.fsExtra.remove(legacyPath); - return { - responseMessage: 'OK', - deleted: { name: themeId }, - }; + if (entry.isDirectory()) { + scanDir(fullPath, relativePath); + } else if (entry.isFile()) { + // Read file and encode as base64 + const content = deps.fs.readFileSync(fullPath); + files[relativePath] = content.toString('base64'); } - - set.status = 404; - return { - responseMessage: 'ERROR', - error: `Theme "${themeId}" not found`, - }; } + } - // Delete the theme directory - await deps.fsExtra.remove(userThemePath); + scanDir(themePath); - return { - responseMessage: 'OK', - deleted: { name: themeId }, - }; - } catch (error) { - console.error('[Themes] Delete error:', error); - set.status = 500; - return { - responseMessage: 'ERROR', - error: error instanceof Error ? error.message : 'Unknown error', - }; - } + return { + themeName, + files, + }; }); diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index 39b36e4ce..f9a2a1539 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -162,7 +162,7 @@ describe('User Routes', () => { const body = await res.json(); expect(body.userPreferences).toBeDefined(); - expect(body.userPreferences.locale.value).toBe('es'); // default + expect(body.userPreferences.locale.value).toBe('en'); // default expect(body.userPreferences.theme.value).toBe('base'); // default }); @@ -524,7 +524,7 @@ describe('User Routes', () => { const body = await res.json(); // Should return defaults even when query fails expect(body.userPreferences).toBeDefined(); - expect(body.userPreferences.locale.value).toBe('es'); // default + expect(body.userPreferences.locale.value).toBe('en'); // default }); it('should handle setPreference errors gracefully (logged but not propagated)', async () => { diff --git a/src/routes/user.ts b/src/routes/user.ts index a90954e7b..c7cb59897 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -34,7 +34,7 @@ const getJwtSecret = () => { * Each preference has a `value` property that the frontend accesses */ const DEFAULT_PREFERENCES = { - locale: { value: 'es' }, + locale: { value: 'en' }, theme: { value: 'base' }, advancedMode: { value: 'true' }, versionControl: { value: 'true' }, diff --git a/src/shared/export/constants.ts b/src/shared/export/constants.ts index 650bee515..fe9cab4c6 100644 --- a/src/shared/export/constants.ts +++ b/src/shared/export/constants.ts @@ -308,6 +308,13 @@ export const LIBRARY_PATTERNS: LibraryPattern[] = [ pattern: 'exe-download-package-link', files: ['fflate/fflate.umd.js', 'exe_elpx_download/exe_elpx_download.js'], }, + // ELPX download support for manual links using exe-package:elp protocol + { + name: 'exe_elpx_download_protocol', + type: 'regex', + pattern: /exe-package:elp/, + files: ['fflate/fflate.umd.js', 'exe_elpx_download/exe_elpx_download.js'], + }, ]; // ============================================================================= diff --git a/src/shared/export/exporters/BaseExporter.ts b/src/shared/export/exporters/BaseExporter.ts index 832e50103..d8c9e452a 100644 --- a/src/shared/export/exporters/BaseExporter.ts +++ b/src/shared/export/exporters/BaseExporter.ts @@ -496,18 +496,21 @@ export abstract class BaseExporter { /** * Pre-process pages to add filenames to asset URLs in all component content - * Also replaces exe-package:elp protocol for download-source-file iDevice * And converts internal links (exe-node:) to proper page URLs + * + * Note: exe-package:elp protocol transformation is now done in PageRenderer.renderPageContent() + * so the XML content keeps the original protocol for re-import compatibility */ async preprocessPagesForExport(pages: ExportPage[]): Promise { - const meta = this.getMetadata(); - const projectTitle = meta.title || 'eXeLearning'; + // Deep clone pages to avoid mutating the original document + // This ensures multiple exports on the same document work correctly + const clonedPages: ExportPage[] = JSON.parse(JSON.stringify(pages)); // Build page URL map for internal link conversion - const pageUrlMap = this.buildPageUrlMap(pages); + const pageUrlMap = this.buildPageUrlMap(clonedPages); - for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) { - const page = pages[pageIndex]; + for (let pageIndex = 0; pageIndex < clonedPages.length; pageIndex++) { + const page = clonedPages[pageIndex]; const isIndex = pageIndex === 0; for (const block of page.blocks || []) { @@ -515,15 +518,13 @@ export abstract class BaseExporter { if (component.content) { // Add filenames to asset URLs component.content = await this.addFilenamesToAssetUrls(component.content); - // Replace exe-package:elp protocol for client-side download - component.content = this.replaceElpxProtocol(component.content, projectTitle); // Convert internal links to proper page URLs component.content = this.replaceInternalLinks(component.content, pageUrlMap, isIndex); } } } } - return pages; + return clonedPages; } /** @@ -650,6 +651,7 @@ export abstract class BaseExporter { /** * Check if a specific page contains the download-source-file iDevice + * or a manual link using exe-package:elp protocol */ protected pageHasDownloadSourceFile(page: ExportPage): boolean { for (const block of page.blocks || []) { @@ -659,10 +661,14 @@ export abstract class BaseExporter { if (type.includes('download-source-file') || type.includes('downloadsourcefile')) { return true; } - // Also check content for the CSS class (more reliable) + // Check content for the CSS class (download-source-file iDevice) if (component.content?.includes('exe-download-package-link')) { return true; } + // Check for manual exe-package:elp links (in text iDevices, etc.) + if (component.content?.includes('exe-package:elp')) { + return true; + } } } return false; diff --git a/src/shared/export/exporters/Html5Exporter.ts b/src/shared/export/exporters/Html5Exporter.ts index 8dfceb513..44779caba 100644 --- a/src/shared/export/exporters/Html5Exporter.ts +++ b/src/shared/export/exporters/Html5Exporter.ts @@ -46,12 +46,13 @@ export class Html5Exporter extends BaseExporter { // Theme priority: 1º parameter > 2º ELP metadata > 3º default const themeName = html5Options?.theme || meta.theme || 'base'; - // Pre-process pages: add filenames to asset URLs - pages = await this.preprocessPagesForExport(pages); - - // Check if download-source-file iDevice is used (needs ELPX manifest for client-side ZIP) + // Check for ELPX download support (looks for exe-package:elp in content) const needsElpxDownload = this.needsElpxDownloadSupport(pages); + // Pre-process pages: add filenames to asset URLs, convert internal links + // Note: exe-package:elp transformation now happens in PageRenderer.renderPageContent() + pages = await this.preprocessPagesForExport(pages); + // File tracking for ELPX manifest (only when download-source-file is used) const fileList: string[] | null = needsElpxDownload ? [] : null; const addFile = (path: string, content: Uint8Array | string) => { @@ -219,6 +220,7 @@ export class Html5Exporter extends BaseExporter { // 8. Detect and fetch additional required libraries based on content // Skip MathJax if LaTeX was pre-rendered to SVG+MathML (unless explicitly requested) // Skip Mermaid if diagrams were pre-rendered to static SVG + // Note: exe-package:elp is still in the content at this point (transformation happens in PageRenderer) const allHtmlContent = this.collectAllHtmlContent(pages); const { files: allRequiredFiles, patterns } = this.libraryDetector.getAllRequiredFilesWithPatterns( allHtmlContent, @@ -288,7 +290,9 @@ export class Html5Exporter extends BaseExporter { const filename = i === 0 ? 'index.html' : `html/${this.sanitizePageFilename(page.title)}.html`; let html = pageHtmlMap.get(filename) || ''; - // Only add manifest script to pages that have download-source-file iDevice + // Only add manifest script to pages that have download-source-file iDevice or exe-package:elp link + // Note: pageHasDownloadSourceFile works correctly because exe-package:elp is not transformed + // in the pages data (transformation happens in PageRenderer during HTML rendering) if (needsElpxDownload && this.pageHasDownloadSourceFile(page)) { const basePath = i === 0 ? '' : '../'; const manifestScriptTag = ``; diff --git a/src/shared/export/exporters/PrintPreviewExporter.ts b/src/shared/export/exporters/PrintPreviewExporter.ts index 5e11e89c7..51d577680 100644 --- a/src/shared/export/exporters/PrintPreviewExporter.ts +++ b/src/shared/export/exporters/PrintPreviewExporter.ts @@ -133,7 +133,9 @@ export class PrintPreviewExporter { const basePath = options.basePath || ''; const version = options.version || 'v1.0.0'; const cleanPath = path.startsWith('/') ? path.slice(1) : path; - return `${baseUrl}${basePath}/${version}/${cleanPath}`; + // Avoid double slashes when basePath ends with / + const cleanBasePath = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath; + return `${baseUrl}${cleanBasePath}/${version}/${cleanPath}`; } /** diff --git a/src/shared/export/exporters/WebsitePreviewExporter.spec.ts b/src/shared/export/exporters/WebsitePreviewExporter.spec.ts index 7c57367cf..df70bb7f8 100644 --- a/src/shared/export/exporters/WebsitePreviewExporter.spec.ts +++ b/src/shared/export/exporters/WebsitePreviewExporter.spec.ts @@ -278,6 +278,24 @@ describe('WebsitePreviewExporter', () => { }); expect(result.html).toContain('/v1.0.0/files/perm/themes/base/base/style.css'); }); + + it('should avoid double slashes when basePath ends with slash in static mode', async () => { + const result = await exporter.generatePreview({ + isStaticMode: true, + basePath: '/', + }); + // Should produce /libs/jquery/jquery.min.js, not //libs/jquery/jquery.min.js + expect(result.html).toContain('src="/libs/jquery/jquery.min.js"'); + expect(result.html).not.toContain('src="//libs/'); + }); + + it('should use relative paths in static mode with dot basePath', async () => { + const result = await exporter.generatePreview({ + isStaticMode: true, + basePath: '.', + }); + expect(result.html).toContain('src="./libs/jquery/jquery.min.js"'); + }); }); describe('iDevice handling', () => { @@ -593,6 +611,87 @@ describe('WebsitePreviewExporter', () => { expect(result.html).toContain('exe_atools.js'); }); }); + + describe('userThemeCss', () => { + it('should use inline style tag when userThemeCss is provided', async () => { + const userCss = '.custom-theme { background: red; color: white; }'; + const result = await exporter.generatePreview({ + userThemeCss: userCss, + }); + // Should contain the inline CSS + expect(result.html).toContain(''); + expect(result.html).toContain(userCss); + // Should NOT contain link tag for theme CSS (since using inline) + expect(result.html).not.toContain(''); + }); + + it('should use link tag when userThemeCss is empty string', async () => { + const result = await exporter.generatePreview({ + userThemeCss: '', + }); + // Empty string is falsy, so should fall back to link tag + expect(result.html).toContain(''); + }); + + it('should preserve user theme CSS with special characters', async () => { + const userCss = '.theme { content: "Hello "; background: url("image.png"); }'; + const result = await exporter.generatePreview({ + userThemeCss: userCss, + }); + expect(result.html).toContain(userCss); + }); + }); + + describe('userThemeJs', () => { + it('should use inline script tag when userThemeJs is provided', async () => { + const userJs = 'var exampleStyle = { init: function() { console.log("loaded"); } };'; + const result = await exporter.generatePreview({ + userThemeJs: userJs, + }); + // Should contain the inline JS + expect(result.html).toContain(''); + expect(result.html).toContain(userJs); + // Should NOT contain script src for theme + expect(result.html).not.toContain('style.js" onerror'); + }); + + it('should use script src when userThemeJs is not provided', async () => { + const result = await exporter.generatePreview({ + baseUrl: 'http://test.com', + version: 'v1.0.0', + }); + // Should contain script src for theme + expect(result.html).toContain('style.js" onerror="this.remove()'); + // Should NOT contain user theme inline JS + expect(result.html).not.toContain(''); + }); + + it('should include both userThemeCss and userThemeJs when both provided', async () => { + const userCss = '.custom { color: red; }'; + const userJs = 'var customTheme = {};'; + const result = await exporter.generatePreview({ + userThemeCss: userCss, + userThemeJs: userJs, + }); + expect(result.html).toContain(''); + expect(result.html).toContain(userCss); + expect(result.html).toContain(''); + expect(result.html).toContain(userJs); + }); + }); }); describe('ELPX protocol handling', () => { diff --git a/src/shared/export/exporters/WebsitePreviewExporter.ts b/src/shared/export/exporters/WebsitePreviewExporter.ts index 3c0ee3207..0badfbfd9 100644 --- a/src/shared/export/exporters/WebsitePreviewExporter.ts +++ b/src/shared/export/exporters/WebsitePreviewExporter.ts @@ -32,12 +32,29 @@ export interface PreviewOptions { version?: string; /** Base path for URLs (e.g., '/exelearning') */ basePath?: string; + /** + * Static mode: use relative paths without version prefix (for offline/static deployments) + */ + isStaticMode?: boolean; /** * Full theme URL from the themes manager (e.g., '/v1/site-files/themes/chiquito/') * When provided, this is used instead of constructing the path from theme name. * This is needed to correctly handle site themes vs base themes. */ themeUrl?: string; + /** + * Inline CSS content for user themes (imported from ELPX, stored in IndexedDB). + * When provided, this CSS is injected as inline `; + } else { + // Server theme: load via link tag with fallback + themeCssSection = `\n`; + } + let head = ` @@ -494,8 +540,7 @@ ${this.generateWebsitePreviewScripts(themeName, usedIdevices, options, needsElpx ${this.getWebsitePreviewCss()} - -`; +${themeCssSection}`; // iDevice CSS from server // Scan export folder for ALL CSS files to include any additional styles @@ -1056,6 +1101,16 @@ window.MathJax = { atoolsScript = `\n`; } + // Build theme JS section: either inline JS for user themes or script src for server themes + let themeJsSection: string; + if (options.userThemeJs) { + // User theme: inject JS inline (theme stored in IndexedDB, not accessible via URL) + themeJsSection = `\n`; + } else { + // Server theme: load via script src with onerror fallback + themeJsSection = ``; + } + return ` ${mathJaxScripts}${detectedLibraryScripts}${ideviceScripts}${atoolsScript} - +${themeJsSection}