diff --git a/assets/styles/pages/_properties.scss b/assets/styles/pages/_properties.scss index 97ae69be9..43c15b756 100644 --- a/assets/styles/pages/_properties.scss +++ b/assets/styles/pages/_properties.scss @@ -264,3 +264,10 @@ .property-row .form-select { transition: box-shadow .15s ease, border-color .15s ease, background-color .15s ease; } + +// Legacy license warning style - uses data attribute for simpler JS +.form-select[data-legacy-value] { + color: var(--system-red, #dc3545) !important; + border-color: var(--system-red, #dc3545) !important; + font-style: italic; +} diff --git a/public/app/workarea/modals/modals/pages/modalFileManager.js b/public/app/workarea/modals/modals/pages/modalFileManager.js index af18cdb8c..e39ae7914 100644 --- a/public/app/workarea/modals/modals/pages/modalFileManager.js +++ b/public/app/workarea/modals/modals/pages/modalFileManager.js @@ -537,7 +537,16 @@ export default class ModalFilemanager extends Modal { Logger.log(`[MediaLibrary] Remote Yjs asset change detected`); // Reload assets from Yjs (fast - metadata only from memory) - this.loadAssets(); + this.loadAssets().then(async () => { + // If a file is selected, refresh the sidebar with updated data + if (this.selectedAsset) { + const updatedAsset = this.assets.find(a => a.id === this.selectedAsset.id); + if (updatedAsset) { + this.selectedAsset = updatedAsset; + await this.showSidebarContent(updatedAsset); + } + } + }); } diff --git a/public/app/workarea/project/properties/formProperties.js b/public/app/workarea/project/properties/formProperties.js index 16177f76d..798b04e2b 100644 --- a/public/app/workarea/project/properties/formProperties.js +++ b/public/app/workarea/project/properties/formProperties.js @@ -405,7 +405,7 @@ export default class FormProperties { valueElement.innerHTML = property.value; valueElement.value = property.value; break; - case 'select': + case 'select': { valueElement = document.createElement('select'); for (let [value, text] of Object.entries( property.options || {} @@ -413,11 +413,15 @@ export default class FormProperties { const optionElement = document.createElement('option'); optionElement.value = value; optionElement.innerHTML = text; - if (value === property.value) + if (value === property.value) { optionElement.setAttribute('selected', 'selected'); + } valueElement.append(optionElement); } + // Legacy license handling is done in YjsPropertiesBinding.updateInputFromYjs() + // which runs after form creation and loads the actual value from Yjs break; + } default: valueElement = document.createElement('div'); break; diff --git a/public/app/workarea/project/properties/formProperties.test.js b/public/app/workarea/project/properties/formProperties.test.js index d59b4984f..5eea094dc 100644 --- a/public/app/workarea/project/properties/formProperties.test.js +++ b/public/app/workarea/project/properties/formProperties.test.js @@ -441,6 +441,7 @@ describe('FormProperties', () => { expect(row.getAttribute('duplicate')).toBe('3'); }); + }); describe('makeRowElementLabel', () => { @@ -592,6 +593,9 @@ describe('FormProperties', () => { expect(element.tagName).toBe('DIV'); }); + + // Note: Legacy license handling tests are in YjsPropertiesBinding.test.js + // as that's where the actual legacy detection and injection logic lives }); describe('addAttributesRowValueElement', () => { diff --git a/public/app/yjs/ElpxImporter.js b/public/app/yjs/ElpxImporter.js index ac56c0d1d..25b6fdf9d 100644 --- a/public/app/yjs/ElpxImporter.js +++ b/public/app/yjs/ElpxImporter.js @@ -268,7 +268,10 @@ class ElpxImporter { author: odeProperties ? (this.getPropertyValue(odeProperties, 'pp_author') || '') : '', language: odeProperties ? (this.getPropertyValue(odeProperties, 'pp_lang') || 'en') : 'en', description: odeProperties ? (this.getPropertyValue(odeProperties, 'pp_description') || '') : '', - license: odeProperties ? (this.getPropertyValue(odeProperties, 'pp_license') || '') : '', + // Check both pp_license (newer format) and license (older v3.x format) for backward compatibility + license: odeProperties ? (this.getPropertyValue(odeProperties, 'pp_license') + ?? this.getPropertyValue(odeProperties, 'license') + ?? '') : '', theme: themeFromXml, // Export settings addPagination: odeProperties ? this.parseBooleanProperty(odeProperties, 'pp_addPagination', false) : false, @@ -1861,6 +1864,11 @@ class ElpxImporter { if (parsedData.meta.extraHeadContent) { metadata.set('extraHeadContent', parsedData.meta.extraHeadContent); } + // Set license if present in legacy file (may be empty for legacy content - that's intentional) + if (parsedData.meta.license !== undefined) { + metadata.set('license', parsedData.meta.license); + Logger.log('[ElpxImporter] Legacy license set:', parsedData.meta.license || '(empty)'); + } // Set export options if present in legacy file // Use metadata keys WITHOUT pp_ prefix to match YjsPropertiesBinding.propertyKeyMap // (LegacyXmlParser returns pp_ prefixed keys, but metadata uses non-prefixed keys) diff --git a/public/app/yjs/ElpxImporter.test.js b/public/app/yjs/ElpxImporter.test.js index 2a671398e..7909fb60c 100644 --- a/public/app/yjs/ElpxImporter.test.js +++ b/public/app/yjs/ElpxImporter.test.js @@ -1000,6 +1000,96 @@ describe('ElpxImporter', () => { // With basic XML (old format), values should use defaults // The test just verifies no errors are thrown }); + + it('extracts license from "license" key (v3.x format) when pp_license is not present', async () => { + // Sample XML with the old "license" key (without pp_ prefix) - used in older v3.x ELPX files + const XML_WITH_OLD_LICENSE_KEY = ` + + + pp_titleTest Project V3 + licensecreative commons: attribution - share alike 4.0 + + + + page1 + Page 1 + 1 + + + +`; + + global.window.fflate = createMockFflate(XML_WITH_OLD_LICENSE_KEY); + const mockDocManager = createMockDocumentManager(); + const oldLicenseImporter = new ElpxImporter(mockDocManager, createMockAssetManager()); + const mockFile = createMockFile(); + + await oldLicenseImporter.importFromFile(mockFile); + + const metadata = mockDocManager.getMetadata(); + expect(metadata.get('license')).toBe('creative commons: attribution - share alike 4.0'); + }); + + it('prefers pp_license over license key when both are present', async () => { + // Sample XML with both keys - pp_license should take precedence + const XML_WITH_BOTH_LICENSE_KEYS = ` + + + pp_titleTest Project + pp_licensepublic domain + licensecreative commons: attribution 4.0 + + + + page1 + Page 1 + 1 + + + +`; + + global.window.fflate = createMockFflate(XML_WITH_BOTH_LICENSE_KEYS); + const mockDocManager = createMockDocumentManager(); + const bothKeysImporter = new ElpxImporter(mockDocManager, createMockAssetManager()); + const mockFile = createMockFile(); + + await bothKeysImporter.importFromFile(mockFile); + + const metadata = mockDocManager.getMetadata(); + // pp_license should take precedence + expect(metadata.get('license')).toBe('public domain'); + }); + + it('handles empty license value from both keys', async () => { + // Sample XML with empty license value + const XML_WITH_EMPTY_LICENSE = ` + + + pp_titleTest Project + license + + + + page1 + Page 1 + 1 + + + +`; + + global.window.fflate = createMockFflate(XML_WITH_EMPTY_LICENSE); + const mockDocManager = createMockDocumentManager(); + const emptyLicenseImporter = new ElpxImporter(mockDocManager, createMockAssetManager()); + const mockFile = createMockFile(); + + await emptyLicenseImporter.importFromFile(mockFile); + + const metadata = mockDocManager.getMetadata(); + // Empty license should be preserved (not defaulted to CC-BY-SA) + expect(metadata.get('license')).toBe(''); + }); }); describe('findNavStructures', () => { diff --git a/public/app/yjs/LegacyXmlParser.js b/public/app/yjs/LegacyXmlParser.js index 6d4b533bf..1733429ea 100644 --- a/public/app/yjs/LegacyXmlParser.js +++ b/public/app/yjs/LegacyXmlParser.js @@ -626,6 +626,7 @@ class LegacyXmlParser { author: '', description: '', language: '', // Language code (e.g., 'es', 'en') + license: '', // License (empty for legacy files with no/unknown license) footer: '', extraHeadContent: '', // Export options (defaults) - use pp_ prefix to match form property names @@ -685,7 +686,15 @@ class LegacyXmlParser { const addAccessibilityToolbar = this.findDictValue(dict, '_addAccessibilityToolbar'); if (addAccessibilityToolbar === true) meta.pp_addAccessibilityToolbar = true; - Logger.log(`[LegacyXmlParser] Metadata: title="${meta.title}"`); + // Extract license - legacy format uses 'license' key with values like "None" or license names + // "None" in legacy format means "no license selected" - treat as empty string + // If not found, meta.license remains '' (the default initialized above) + const license = this.findDictValue(dict, 'license'); + if (license !== null && license !== undefined) { + meta.license = (license === 'None') ? '' : license; + } + + Logger.log(`[LegacyXmlParser] Metadata: title="${meta.title}", license="${meta.license}"`); return meta; } diff --git a/public/app/yjs/LegacyXmlParser.test.js b/public/app/yjs/LegacyXmlParser.test.js index 5fb756510..0436c65d6 100644 --- a/public/app/yjs/LegacyXmlParser.test.js +++ b/public/app/yjs/LegacyXmlParser.test.js @@ -257,6 +257,86 @@ describe('LegacyXmlParser', () => { expect(meta.pp_addAccessibilityToolbar).toBe(false); }); + it('extracts license from package', () => { + const xml = ` + + + + + + + + + + `; + + parser.parse(xml); + const meta = parser.extractMetadata(); + + expect(meta.title).toBe('Project With License'); + expect(meta.license).toBe('creative commons: attribution - share alike 4.0'); + }); + + it('converts "None" license value to empty string', () => { + // Legacy eXe 2.x used "None" to indicate no license selected + const xml = ` + + + + + + + + + + `; + + parser.parse(xml); + const meta = parser.extractMetadata(); + + expect(meta.title).toBe('Project With None License'); + // "None" should be converted to empty string (no license) + expect(meta.license).toBe(''); + }); + + it('returns empty license when not present', () => { + const xml = ` + + + + + + + + `; + + parser.parse(xml); + const meta = parser.extractMetadata(); + + // Missing license should be empty string + expect(meta.license).toBe(''); + }); + + it('preserves empty license value from file', () => { + const xml = ` + + + + + + + + + + `; + + parser.parse(xml); + const meta = parser.extractMetadata(); + + // Explicitly empty license should stay empty (not default to CC-BY-SA) + expect(meta.license).toBe(''); + }); + it('has all expected metadata properties', () => { const xml = ` @@ -276,6 +356,7 @@ describe('LegacyXmlParser', () => { expect(meta).toHaveProperty('author'); expect(meta).toHaveProperty('description'); expect(meta).toHaveProperty('language'); + expect(meta).toHaveProperty('license'); expect(meta).toHaveProperty('footer'); expect(meta).toHaveProperty('extraHeadContent'); expect(meta).toHaveProperty('exportSource'); diff --git a/public/app/yjs/YjsPropertiesBinding.js b/public/app/yjs/YjsPropertiesBinding.js index 9bd6f9873..0525edc9f 100644 --- a/public/app/yjs/YjsPropertiesBinding.js +++ b/public/app/yjs/YjsPropertiesBinding.js @@ -157,6 +157,17 @@ class YjsPropertiesBinding { const inputListener = (event) => { if (this.isUpdatingFromYjs) return; + // If this is a select with legacy warning and user selected a valid option, clean up immediately + if (inputType === 'select' && input.dataset.legacyValue === 'true') { + const selectedValue = input.value; + const isValidOption = Array.from(input.options).some( + opt => opt.value === selectedValue && !opt.dataset.legacySynthetic + ); + if (isValidOption) { + this.removeLegacyWarning(input); + } + } + // Notify immediately that there are pending changes (for instant UI feedback) // This enables undo buttons before the debounce timer fires this.notifyPendingChange(); @@ -303,6 +314,17 @@ class YjsPropertiesBinding { input.checked = value === 'true' || value === true; break; case 'select': + // Check if value exists in options (excluding synthetic legacy options) + const optionExists = Array.from(input.options).some( + opt => opt.value === value && !opt.dataset.legacySynthetic + ); + if (value && !optionExists) { + // Value not in options (legacy license) - inject synthetic option + this.injectLegacyOption(input, value); + } else { + // Valid value selected - remove legacy warning if present + this.removeLegacyWarning(input); + } input.value = value; break; case 'text': @@ -317,6 +339,42 @@ class YjsPropertiesBinding { } } + /** + * Inject a synthetic option for legacy values not in the select options. + * CSS styling is handled via [data-legacy-value] attribute selector. + * @param {HTMLSelectElement} select - The select element + * @param {string} value - The legacy value to inject + */ + injectLegacyOption(select, value) { + // Remove any existing synthetic option first + this.removeLegacyWarning(select); + + // Create synthetic option with warning text + const syntheticOption = document.createElement('option'); + syntheticOption.value = value; + syntheticOption.textContent = `${value} — ⚠️ ${_('Legacy license, please review')}`; + syntheticOption.selected = true; + syntheticOption.dataset.legacySynthetic = 'true'; + select.insertBefore(syntheticOption, select.firstChild); + + // Mark select for CSS styling via data attribute + select.dataset.legacyValue = 'true'; + + Logger.log(`[YjsPropertiesBinding] Injected legacy option: ${value}`); + } + + /** + * Remove legacy warning and synthetic option from select. + * @param {HTMLSelectElement} select - The select element + */ + removeLegacyWarning(select) { + const syntheticOption = select.querySelector('option[data-legacy-synthetic]'); + if (syntheticOption) { + syntheticOption.remove(); + } + delete select.dataset.legacyValue; + } + /** * Setup observer for remote changes to metadata */ diff --git a/public/app/yjs/YjsPropertiesBinding.test.js b/public/app/yjs/YjsPropertiesBinding.test.js index ee20892ab..a9f667774 100644 --- a/public/app/yjs/YjsPropertiesBinding.test.js +++ b/public/app/yjs/YjsPropertiesBinding.test.js @@ -719,4 +719,122 @@ describe('YjsPropertiesBinding', () => { expect(binding.mapMetadataKeyToProperty('addMathJax')).toBe('pp_addMathJax'); }); }); + + describe('legacy license detection', () => { + it('injects synthetic option when select value is not in options', () => { + // Create a select with limited options (non-legacy licenses only) + const select = document.createElement('select'); + select.className = 'property-value'; + select.setAttribute('property', 'pp_license'); + select.setAttribute('data-type', 'select'); + + const option1 = document.createElement('option'); + option1.value = 'creative commons: attribution 4.0'; + option1.text = 'CC BY 4.0'; + select.appendChild(option1); + + const option2 = document.createElement('option'); + option2.value = 'public domain'; + option2.text = 'Public Domain'; + select.appendChild(option2); + + // Set a legacy license value in Yjs + binding.metadata.set('license', 'creative commons: attribution 3.0'); + + // Update the select from Yjs + binding.updateInputFromYjs(select, 'license', 'select'); + + // Verify synthetic option was injected with warning in text + expect(select.options.length).toBe(3); + expect(select.options[0].value).toBe('creative commons: attribution 3.0'); + expect(select.options[0].textContent).toContain('creative commons: attribution 3.0'); + expect(select.options[0].textContent).toContain('⚠️'); + expect(select.options[0].textContent).toContain('Legacy'); + expect(select.dataset.legacyValue).toBe('true'); + }); + + it('does NOT inject synthetic option when value is in options', () => { + const select = document.createElement('select'); + select.className = 'property-value'; + select.setAttribute('property', 'pp_license'); + select.setAttribute('data-type', 'select'); + + const option1 = document.createElement('option'); + option1.value = 'creative commons: attribution 4.0'; + option1.text = 'CC BY 4.0'; + select.appendChild(option1); + + // Set a valid (non-legacy) license value in Yjs + binding.metadata.set('license', 'creative commons: attribution 4.0'); + + // Update the select from Yjs + binding.updateInputFromYjs(select, 'license', 'select'); + + // Should NOT have injected any option + expect(select.options.length).toBe(1); + expect(select.dataset.legacyValue).toBeUndefined(); + }); + + it('injectLegacyOption creates correct synthetic option with warning in text', () => { + const select = document.createElement('select'); + + binding.injectLegacyOption(select, 'license gfdl'); + + expect(select.options.length).toBe(1); + expect(select.options[0].value).toBe('license gfdl'); + expect(select.options[0].textContent).toContain('license gfdl'); + expect(select.options[0].textContent).toContain('⚠️'); + expect(select.options[0].textContent).toContain('Legacy'); + expect(select.options[0].selected).toBe(true); + expect(select.options[0].dataset.legacySynthetic).toBe('true'); + // CSS styling via data attribute, no class needed + expect(select.dataset.legacyValue).toBe('true'); + }); + + it('removes legacy warning when valid license is selected', () => { + const select = document.createElement('select'); + select.className = 'property-value'; + + // Add a valid option + const validOption = document.createElement('option'); + validOption.value = 'public domain'; + validOption.text = 'Public Domain'; + select.appendChild(validOption); + + // First set a legacy license + binding.metadata.set('license', 'gnu/gpl'); + binding.updateInputFromYjs(select, 'license', 'select'); + + // Verify legacy warning is present via data attribute + expect(select.dataset.legacyValue).toBe('true'); + expect(select.options.length).toBe(2); // synthetic + valid + + // Now select a valid license + binding.metadata.set('license', 'public domain'); + binding.updateInputFromYjs(select, 'license', 'select'); + + // Verify legacy warning is removed + expect(select.dataset.legacyValue).toBeUndefined(); + expect(select.options.length).toBe(1); // only valid option remains + expect(select.value).toBe('public domain'); + }); + + it('removeLegacyWarning cleans up select element', () => { + const select = document.createElement('select'); + + // Inject legacy option first + binding.injectLegacyOption(select, 'free software license eupl'); + + // Verify it was added + expect(select.options.length).toBe(1); + expect(select.dataset.legacyValue).toBe('true'); + + // Remove it + binding.removeLegacyWarning(select); + + // Verify it was removed + expect(select.options.length).toBe(0); + expect(select.dataset.legacyValue).toBeUndefined(); + }); + }); }); diff --git a/src/routes/config.ts b/src/routes/config.ts index 3b05bb9bc..266b6fe3b 100644 --- a/src/routes/config.ts +++ b/src/routes/config.ts @@ -30,20 +30,19 @@ import * as path from 'path'; import { getDefaultTheme, getDefaultThemeRecord } from '../db/queries/themes'; import { SUPPORTED_LOCALES } from '../services/admin-upload-validator'; import { getBasePath } from '../utils/basepath.util'; +import { LICENSE_REGISTRY } from '../shared/export/constants'; /** - * Available licenses for content + * Available licenses for content dropdown + * Derived from LICENSE_REGISTRY - the single source of truth + * Legacy licenses (marked with legacy: true) are excluded from the dropdown + * but are preserved when already set on a project */ -const LICENSES = { - 'creative commons: attribution 4.0': `${TRANS_PREFIX}creative commons: attribution 4.0 (BY)`, - 'creative commons: attribution - share alike 4.0': `${TRANS_PREFIX}creative commons: attribution - share alike 4.0 (BY-SA)`, - 'creative commons: attribution - non derived work 4.0': `${TRANS_PREFIX}creative commons: attribution - non derived work 4.0 (BY-ND)`, - 'creative commons: attribution - non commercial 4.0': `${TRANS_PREFIX}creative commons: attribution - non commercial 4.0 (BY-NC)`, - 'creative commons: attribution - non commercial - share alike 4.0': `${TRANS_PREFIX}creative commons: attribution - non commercial - share alike 4.0 (BY-NC-SA)`, - 'creative commons: attribution - non derived work - non commercial 4.0': `${TRANS_PREFIX}creative commons: attribution - non derived work - non commercial 4.0 (BY-NC-ND)`, - 'public domain': `${TRANS_PREFIX}public domain`, - 'propietary license': `${TRANS_PREFIX}proprietary license`, -}; +const LICENSES: Record = Object.fromEntries( + Object.entries(LICENSE_REGISTRY) + .filter(([, entry]) => !entry.legacy) + .map(([key, entry]) => [key, `${TRANS_PREFIX}${entry.displayName}`]), +); /** * User preferences configuration (expected by frontend) diff --git a/src/services/export/html-generator.spec.ts b/src/services/export/html-generator.spec.ts index 76c4aa3f0..24bd5a320 100644 --- a/src/services/export/html-generator.spec.ts +++ b/src/services/export/html-generator.spec.ts @@ -384,7 +384,7 @@ describe('HTML Generator Helper', () => { class: 'cc cc-by-nc-nd', }, { name: 'public domain', class: 'cc cc-0' }, - { name: 'propietary license', class: 'propietary' }, + { name: 'propietary license', class: '' }, ]; for (const lic of licenses) { diff --git a/src/shared/export/constants.spec.ts b/src/shared/export/constants.spec.ts index 92b87fc45..7d53071e8 100644 --- a/src/shared/export/constants.spec.ts +++ b/src/shared/export/constants.spec.ts @@ -22,6 +22,9 @@ import { normalizeIdeviceType, formatLicenseText, getLicenseClass, + getLicenseUrl, + LICENSE_REGISTRY, + shouldShowLicenseFooter, } from './constants'; import { resetIdeviceConfigCache, loadIdeviceConfigs } from '../../services/idevice-config'; @@ -383,18 +386,205 @@ describe('Constants', () => { }); }); - describe('License Functions', () => { - describe('getLicenseUrl', () => { - // Import getLicenseUrl for testing - const { getLicenseUrl } = require('./constants'); + describe('License Registry', () => { + describe('LICENSE_REGISTRY', () => { + it('should have all CC 4.0 licenses', () => { + expect(LICENSE_REGISTRY['creative commons: attribution 4.0']).toBeDefined(); + expect(LICENSE_REGISTRY['creative commons: attribution - share alike 4.0']).toBeDefined(); + expect(LICENSE_REGISTRY['creative commons: attribution - non derived work 4.0']).toBeDefined(); + expect(LICENSE_REGISTRY['creative commons: attribution - non commercial 4.0']).toBeDefined(); + expect( + LICENSE_REGISTRY['creative commons: attribution - non commercial - share alike 4.0'], + ).toBeDefined(); + expect( + LICENSE_REGISTRY['creative commons: attribution - non derived work - non commercial 4.0'], + ).toBeDefined(); + }); + + it('should have all CC 3.0 licenses', () => { + expect(LICENSE_REGISTRY['creative commons: attribution 3.0']).toBeDefined(); + expect(LICENSE_REGISTRY['creative commons: attribution - share alike 3.0']).toBeDefined(); + expect(LICENSE_REGISTRY['creative commons: attribution - non derived work 3.0']).toBeDefined(); + expect(LICENSE_REGISTRY['creative commons: attribution - non commercial 3.0']).toBeDefined(); + expect( + LICENSE_REGISTRY['creative commons: attribution - non commercial - share alike 3.0'], + ).toBeDefined(); + expect( + LICENSE_REGISTRY['creative commons: attribution - non derived work - non commercial 3.0'], + ).toBeDefined(); + }); + + it('should have all CC 2.5 licenses', () => { + expect(LICENSE_REGISTRY['creative commons: attribution 2.5']).toBeDefined(); + expect(LICENSE_REGISTRY['creative commons: attribution - share alike 2.5']).toBeDefined(); + expect(LICENSE_REGISTRY['creative commons: attribution - non derived work 2.5']).toBeDefined(); + expect(LICENSE_REGISTRY['creative commons: attribution - non commercial 2.5']).toBeDefined(); + expect( + LICENSE_REGISTRY['creative commons: attribution - non commercial - share alike 2.5'], + ).toBeDefined(); + expect( + LICENSE_REGISTRY['creative commons: attribution - non derived work - non commercial 2.5'], + ).toBeDefined(); + }); + + it('should have GPL, EUPL, and GFDL licenses', () => { + expect(LICENSE_REGISTRY['gnu/gpl']).toBeDefined(); + expect(LICENSE_REGISTRY['free software license gpl']).toBeDefined(); + expect(LICENSE_REGISTRY['free software license eupl']).toBeDefined(); + expect(LICENSE_REGISTRY['dual free content license gpl and eupl']).toBeDefined(); + expect(LICENSE_REGISTRY['license gfdl']).toBeDefined(); + }); + + it('should have other license types', () => { + expect(LICENSE_REGISTRY['public domain']).toBeDefined(); + expect(LICENSE_REGISTRY['propietary license']).toBeDefined(); + expect(LICENSE_REGISTRY['intellectual property license']).toBeDefined(); + expect(LICENSE_REGISTRY['not appropriate']).toBeDefined(); + expect(LICENSE_REGISTRY['other free software licenses']).toBeDefined(); + }); + + it('should have correct structure for each entry', () => { + for (const [key, entry] of Object.entries(LICENSE_REGISTRY)) { + expect(entry.displayName).toBeDefined(); + expect(typeof entry.displayName).toBe('string'); + expect(typeof entry.url).toBe('string'); + expect(typeof entry.cssClass).toBe('string'); + } + }); + + it('should include short codes in displayName for CC licenses', () => { + expect(LICENSE_REGISTRY['creative commons: attribution 4.0'].displayName).toContain('(BY)'); + expect(LICENSE_REGISTRY['creative commons: attribution - share alike 4.0'].displayName).toContain( + '(BY-SA)', + ); + expect(LICENSE_REGISTRY['creative commons: attribution - non commercial 4.0'].displayName).toContain( + '(BY-NC)', + ); + }); - it('should return default URL for empty license', () => { - expect(getLicenseUrl('')).toBe('https://creativecommons.org/licenses/by-sa/4.0/'); - expect(getLicenseUrl(null as unknown as string)).toBe( - 'https://creativecommons.org/licenses/by-sa/4.0/', + it('should have correct URLs for CC 3.0 licenses', () => { + expect(LICENSE_REGISTRY['creative commons: attribution 3.0'].url).toBe( + 'https://creativecommons.org/licenses/by/3.0/', + ); + expect(LICENSE_REGISTRY['creative commons: attribution - share alike 3.0'].url).toBe( + 'https://creativecommons.org/licenses/by-sa/3.0/', ); }); + it('should have correct URLs for CC 2.5 licenses', () => { + expect(LICENSE_REGISTRY['creative commons: attribution 2.5'].url).toBe( + 'https://creativecommons.org/licenses/by/2.5/', + ); + expect(LICENSE_REGISTRY['creative commons: attribution - share alike 2.5'].url).toBe( + 'https://creativecommons.org/licenses/by-sa/2.5/', + ); + }); + + it('should have empty URLs for licenses without official URLs', () => { + expect(LICENSE_REGISTRY['free software license eupl'].url).toBe(''); + expect(LICENSE_REGISTRY['free software license gpl'].url).toBe(''); + expect(LICENSE_REGISTRY['license gfdl'].url).toBe(''); + expect(LICENSE_REGISTRY['intellectual property license'].url).toBe(''); + expect(LICENSE_REGISTRY['not appropriate'].url).toBe(''); + }); + + it('should have total of 28 licenses', () => { + // 6 CC 4.0 + 6 CC 3.0 + 6 CC 2.5 + 2 GPL + 1 EUPL + 1 dual + 1 GFDL + // + 1 public domain + 1 propietary + 1 IP + 1 not appropriate + 1 other = 28 + expect(Object.keys(LICENSE_REGISTRY).length).toBe(28); + }); + + it('should mark CC 3.0 licenses as legacy', () => { + expect(LICENSE_REGISTRY['creative commons: attribution 3.0'].legacy).toBe(true); + expect(LICENSE_REGISTRY['creative commons: attribution - share alike 3.0'].legacy).toBe(true); + expect(LICENSE_REGISTRY['creative commons: attribution - non derived work 3.0'].legacy).toBe(true); + expect(LICENSE_REGISTRY['creative commons: attribution - non commercial 3.0'].legacy).toBe(true); + expect( + LICENSE_REGISTRY['creative commons: attribution - non commercial - share alike 3.0'].legacy, + ).toBe(true); + expect( + LICENSE_REGISTRY['creative commons: attribution - non derived work - non commercial 3.0'].legacy, + ).toBe(true); + }); + + it('should mark CC 2.5 licenses as legacy', () => { + expect(LICENSE_REGISTRY['creative commons: attribution 2.5'].legacy).toBe(true); + expect(LICENSE_REGISTRY['creative commons: attribution - share alike 2.5'].legacy).toBe(true); + expect(LICENSE_REGISTRY['creative commons: attribution - non derived work 2.5'].legacy).toBe(true); + expect(LICENSE_REGISTRY['creative commons: attribution - non commercial 2.5'].legacy).toBe(true); + expect( + LICENSE_REGISTRY['creative commons: attribution - non commercial - share alike 2.5'].legacy, + ).toBe(true); + expect( + LICENSE_REGISTRY['creative commons: attribution - non derived work - non commercial 2.5'].legacy, + ).toBe(true); + }); + + it('should mark GPL, EUPL, GFDL and other free software licenses as legacy', () => { + expect(LICENSE_REGISTRY['gnu/gpl'].legacy).toBe(true); + expect(LICENSE_REGISTRY['free software license gpl'].legacy).toBe(true); + expect(LICENSE_REGISTRY['free software license eupl'].legacy).toBe(true); + expect(LICENSE_REGISTRY['dual free content license gpl and eupl'].legacy).toBe(true); + expect(LICENSE_REGISTRY['license gfdl'].legacy).toBe(true); + expect(LICENSE_REGISTRY['other free software licenses'].legacy).toBe(true); + }); + + it('should NOT mark CC 4.0 licenses as legacy (available in dropdown)', () => { + expect(LICENSE_REGISTRY['creative commons: attribution 4.0'].legacy).toBeUndefined(); + expect(LICENSE_REGISTRY['creative commons: attribution - share alike 4.0'].legacy).toBeUndefined(); + expect(LICENSE_REGISTRY['creative commons: attribution - non derived work 4.0'].legacy).toBeUndefined(); + expect(LICENSE_REGISTRY['creative commons: attribution - non commercial 4.0'].legacy).toBeUndefined(); + expect( + LICENSE_REGISTRY['creative commons: attribution - non commercial - share alike 4.0'].legacy, + ).toBeUndefined(); + expect( + LICENSE_REGISTRY['creative commons: attribution - non derived work - non commercial 4.0'].legacy, + ).toBeUndefined(); + }); + + it('should NOT mark public domain, propietary, and not appropriate as legacy', () => { + expect(LICENSE_REGISTRY['public domain'].legacy).toBeUndefined(); + expect(LICENSE_REGISTRY['propietary license'].legacy).toBeUndefined(); + expect(LICENSE_REGISTRY['not appropriate'].legacy).toBeUndefined(); + }); + + it('should mark intellectual property license as legacy', () => { + expect(LICENSE_REGISTRY['intellectual property license'].legacy).toBe(true); + }); + + it('should mark propietary license and not appropriate with hideInFooter', () => { + expect(LICENSE_REGISTRY['propietary license'].hideInFooter).toBe(true); + expect(LICENSE_REGISTRY['not appropriate'].hideInFooter).toBe(true); + }); + + it('should NOT mark other licenses with hideInFooter', () => { + expect(LICENSE_REGISTRY['creative commons: attribution 4.0'].hideInFooter).toBeUndefined(); + expect(LICENSE_REGISTRY['public domain'].hideInFooter).toBeUndefined(); + expect(LICENSE_REGISTRY['intellectual property license'].hideInFooter).toBeUndefined(); + }); + + it('should have exactly 19 legacy licenses and 9 non-legacy licenses', () => { + const legacyCount = Object.values(LICENSE_REGISTRY).filter(e => e.legacy === true).length; + const nonLegacyCount = Object.values(LICENSE_REGISTRY).filter(e => !e.legacy).length; + // CC 3.0: 6 licenses + // CC 2.5: 6 licenses + // GNU/GPL: 1, free software gpl: 1, EUPL: 1, dual: 1, GFDL: 1, other: 1, IP: 1 = 7 + // Total legacy: 6 + 6 + 7 = 19 + // Non-legacy: CC 4.0 (6) + public domain (1) + propietary (1) + not appropriate (1) = 9 + // Total: 19 + 9 = 28 + expect(legacyCount).toBe(19); + expect(nonLegacyCount).toBe(9); + }); + }); + }); + + describe('License Functions', () => { + describe('getLicenseUrl', () => { + it('should return empty string for empty license (no license specified)', () => { + expect(getLicenseUrl('')).toBe(''); + expect(getLicenseUrl(null as unknown as string)).toBe(''); + }); + it('should return correct URL for CC BY-SA license', () => { expect(getLicenseUrl('creative commons: attribution - share alike 4.0')).toBe( 'https://creativecommons.org/licenses/by-sa/4.0/', @@ -454,6 +644,44 @@ describe('Constants', () => { 'https://creativecommons.org/licenses/by-nc-nd/4.0/', ); }); + + // New tests for CC 3.0 and 2.5 licenses + it('should return correct URLs for CC 3.0 licenses', () => { + expect(getLicenseUrl('creative commons: attribution 3.0')).toBe( + 'https://creativecommons.org/licenses/by/3.0/', + ); + expect(getLicenseUrl('creative commons: attribution - share alike 3.0')).toBe( + 'https://creativecommons.org/licenses/by-sa/3.0/', + ); + expect(getLicenseUrl('creative commons: attribution - non commercial 3.0')).toBe( + 'https://creativecommons.org/licenses/by-nc/3.0/', + ); + }); + + it('should return correct URLs for CC 2.5 licenses', () => { + expect(getLicenseUrl('creative commons: attribution 2.5')).toBe( + 'https://creativecommons.org/licenses/by/2.5/', + ); + expect(getLicenseUrl('creative commons: attribution - share alike 2.5')).toBe( + 'https://creativecommons.org/licenses/by-sa/2.5/', + ); + }); + + // New tests for licenses without URLs + it('should return empty string for licenses without URLs', () => { + expect(getLicenseUrl('free software license eupl')).toBe(''); + expect(getLicenseUrl('free software license gpl')).toBe(''); + expect(getLicenseUrl('license gfdl')).toBe(''); + expect(getLicenseUrl('intellectual property license')).toBe(''); + expect(getLicenseUrl('not appropriate')).toBe(''); + expect(getLicenseUrl('propietary license')).toBe(''); + }); + + it('should return GPL URL for gnu/gpl but empty for other GPL variants', () => { + expect(getLicenseUrl('gnu/gpl')).toBe('https://www.gnu.org/licenses/gpl.html'); + // 'free software license gpl' has no URL in original eXe + expect(getLicenseUrl('free software license gpl')).toBe(''); + }); }); describe('formatLicenseText', () => { @@ -476,9 +704,9 @@ describe('Constants', () => { expect(formatLicenseText('Cc-By-Sa')).toBe('creative commons: attribution - share alike 4.0'); }); - it('should pass through already full names', () => { + it('should pass through already full names (with short codes)', () => { expect(formatLicenseText('creative commons: attribution - share alike 4.0')).toBe( - 'creative commons: attribution - share alike 4.0', + 'creative commons: attribution - share alike 4.0 (BY-SA)', ); expect(formatLicenseText('public domain')).toBe('public domain'); expect(formatLicenseText('propietary license')).toBe('propietary license'); @@ -490,36 +718,161 @@ describe('Constants', () => { expect(formatLicenseText('public domain')).toBe('public domain'); }); - it('should return default for empty input', () => { - expect(formatLicenseText('')).toBe('creative commons: attribution - share alike 4.0'); + it('should return empty string for empty input (no license specified)', () => { + expect(formatLicenseText('')).toBe(''); }); it('should trim whitespace', () => { expect(formatLicenseText(' CC-BY-SA ')).toBe('creative commons: attribution - share alike 4.0'); }); + + // New tests for CC 3.0 and 2.5 licenses + it('should pass through CC 3.0 licenses (with short codes)', () => { + expect(formatLicenseText('creative commons: attribution 3.0')).toBe( + 'creative commons: attribution 3.0 (BY)', + ); + expect(formatLicenseText('creative commons: attribution - share alike 3.0')).toBe( + 'creative commons: attribution - share alike 3.0 (BY-SA)', + ); + }); + + it('should pass through CC 2.5 licenses (with short codes)', () => { + expect(formatLicenseText('creative commons: attribution 2.5')).toBe( + 'creative commons: attribution 2.5 (BY)', + ); + expect(formatLicenseText('creative commons: attribution - share alike 2.5')).toBe( + 'creative commons: attribution - share alike 2.5 (BY-SA)', + ); + }); + + // New tests for GPL, EUPL, GFDL, and other licenses + it('should format GPL licenses', () => { + expect(formatLicenseText('gnu/gpl')).toBe('gnu/gpl'); + expect(formatLicenseText('free software license gpl')).toBe('free software license GPL'); + }); + + it('should format EUPL license', () => { + expect(formatLicenseText('free software license eupl')).toBe('free software license EUPL'); + }); + + it('should format dual GPL/EUPL license', () => { + expect(formatLicenseText('dual free content license gpl and eupl')).toBe( + 'dual free content license GPL and EUPL', + ); + }); + + it('should format GFDL license', () => { + expect(formatLicenseText('license gfdl')).toBe('license GFDL'); + }); + + it('should format other license types', () => { + expect(formatLicenseText('intellectual property license')).toBe('intellectual property license'); + expect(formatLicenseText('not appropriate')).toBe('not appropriate'); + expect(formatLicenseText('other free software licenses')).toBe('other free software licenses'); + }); + + it('should fallback to keyword matching for partial matches', () => { + expect(formatLicenseText('some eupl license')).toBe('free software license EUPL'); + expect(formatLicenseText('gfdl documentation')).toBe('license GFDL'); + expect(formatLicenseText('gpl open source')).toBe('gnu/gpl'); + }); }); describe('getLicenseClass', () => { - it('should return correct CSS classes for licenses', () => { - expect(getLicenseClass('CC-BY-SA')).toBe('cc cc-by-sa'); - expect(getLicenseClass('CC-BY')).toBe('cc'); - expect(getLicenseClass('CC-BY-NC')).toBe('cc cc-by-nc'); - expect(getLicenseClass('CC-BY-ND')).toBe('cc cc-by-nd'); - expect(getLicenseClass('CC-BY-NC-SA')).toBe('cc cc-by-nc-sa'); - expect(getLicenseClass('CC-BY-NC-ND')).toBe('cc cc-by-nc-nd'); + // getLicenseClass looks up cssClass from LICENSE_REGISTRY by license name + + it('should return correct CSS class for CC 4.0 licenses', () => { + expect(getLicenseClass('creative commons: attribution 4.0')).toBe('cc'); + expect(getLicenseClass('creative commons: attribution - share alike 4.0')).toBe('cc cc-by-sa'); + expect(getLicenseClass('creative commons: attribution - non commercial 4.0')).toBe('cc cc-by-nc'); + expect(getLicenseClass('creative commons: attribution - non derived work 4.0')).toBe('cc cc-by-nd'); + expect(getLicenseClass('creative commons: attribution - non commercial - share alike 4.0')).toBe( + 'cc cc-by-nc-sa', + ); + expect(getLicenseClass('creative commons: attribution - non derived work - non commercial 4.0')).toBe( + 'cc cc-by-nc-nd', + ); }); - it('should return correct class for public domain', () => { + it('should return correct CSS class for CC 3.0 licenses', () => { + expect(getLicenseClass('creative commons: attribution 3.0')).toBe('cc'); + expect(getLicenseClass('creative commons: attribution - share alike 3.0')).toBe('cc cc-by-sa'); + expect(getLicenseClass('creative commons: attribution - non commercial 3.0')).toBe('cc cc-by-nc'); + }); + + it('should return correct CSS class for CC 2.5 licenses', () => { + expect(getLicenseClass('creative commons: attribution 2.5')).toBe('cc'); + expect(getLicenseClass('creative commons: attribution - share alike 2.5')).toBe('cc cc-by-sa'); + }); + + it('should return cc cc-0 for public domain', () => { expect(getLicenseClass('public domain')).toBe('cc cc-0'); - expect(getLicenseClass('CC0')).toBe('cc cc-0'); }); - it('should return correct class for proprietary', () => { - expect(getLicenseClass('propietary license')).toBe('propietary'); + it('should return empty class for propietary license (shows text without icon)', () => { + expect(getLicenseClass('propietary license')).toBe(''); + }); + + it('should handle case insensitivity', () => { + expect(getLicenseClass('CREATIVE COMMONS: ATTRIBUTION 4.0')).toBe('cc'); + expect(getLicenseClass('Public Domain')).toBe('cc cc-0'); + }); + + it('should return empty string for empty input', () => { + expect(getLicenseClass('')).toBe(''); + }); + + it('should return empty string for licenses without icons', () => { + expect(getLicenseClass('gnu/gpl')).toBe(''); + expect(getLicenseClass('free software license eupl')).toBe(''); + expect(getLicenseClass('license gfdl')).toBe(''); + expect(getLicenseClass('intellectual property license')).toBe(''); + expect(getLicenseClass('not appropriate')).toBe(''); + expect(getLicenseClass('other free software licenses')).toBe(''); + }); + + it('should return empty for unknown licenses', () => { + expect(getLicenseClass('unknown license')).toBe(''); + expect(getLicenseClass('some random text')).toBe(''); + }); + }); + + describe('shouldShowLicenseFooter', () => { + it('should return false for empty license', () => { + expect(shouldShowLicenseFooter('')).toBe(false); + expect(shouldShowLicenseFooter(null as unknown as string)).toBe(false); + expect(shouldShowLicenseFooter(undefined as unknown as string)).toBe(false); + }); + + it('should return false for propietary license', () => { + expect(shouldShowLicenseFooter('propietary license')).toBe(false); + expect(shouldShowLicenseFooter('Propietary License')).toBe(false); + expect(shouldShowLicenseFooter('PROPIETARY LICENSE')).toBe(false); + }); + + it('should return false for not appropriate', () => { + expect(shouldShowLicenseFooter('not appropriate')).toBe(false); + expect(shouldShowLicenseFooter('Not Appropriate')).toBe(false); + expect(shouldShowLicenseFooter('NOT APPROPRIATE')).toBe(false); + }); + + it('should return true for CC licenses', () => { + expect(shouldShowLicenseFooter('creative commons: attribution 4.0')).toBe(true); + expect(shouldShowLicenseFooter('creative commons: attribution - share alike 4.0')).toBe(true); + }); + + it('should return true for public domain', () => { + expect(shouldShowLicenseFooter('public domain')).toBe(true); + }); + + it('should return true for legacy licenses (they still display)', () => { + expect(shouldShowLicenseFooter('creative commons: attribution 3.0')).toBe(true); + expect(shouldShowLicenseFooter('gnu/gpl')).toBe(true); + expect(shouldShowLicenseFooter('intellectual property license')).toBe(true); }); - it('should return default for empty input', () => { - expect(getLicenseClass('')).toBe('cc cc-by-sa'); + it('should return true for unknown licenses', () => { + expect(shouldShowLicenseFooter('some random license')).toBe(true); }); }); }); diff --git a/src/shared/export/constants.ts b/src/shared/export/constants.ts index 3e8db9e44..445f7c749 100644 --- a/src/shared/export/constants.ts +++ b/src/shared/export/constants.ts @@ -386,111 +386,280 @@ export function getExtensionFromMime(mime: string): string { } // ============================================================================= -// License Mappings +// License Registry (Single Source of Truth) // ============================================================================= /** - * Maps license names to their CSS class names for the icon display + * License entry in the registry */ -export const LICENSE_CLASS_MAP: Record = { - 'creative commons: attribution 4.0': 'cc', - 'creative commons: attribution - share alike 4.0': 'cc cc-by-sa', - 'creative commons: attribution - non derived work 4.0': 'cc cc-by-nd', - 'creative commons: attribution - non commercial 4.0': 'cc cc-by-nc', - 'creative commons: attribution - non commercial - share alike 4.0': 'cc cc-by-nc-sa', - 'creative commons: attribution - non derived work - non commercial 4.0': 'cc cc-by-nc-nd', - 'public domain': 'cc cc-0', - 'propietary license': 'propietary', +export interface LicenseEntry { + /** Full display name with version and short code */ + displayName: string; + /** Official license URL (empty if none) */ + url: string; + /** CSS class for license icon (only CC and propietary have icons in themes) */ + cssClass: string; + /** If true, license is preserved but not selectable in dropdown (legacy from older eXe versions) */ + legacy?: boolean; + /** If true, no license section is shown in export footer (e.g., propietary, not appropriate) */ + hideInFooter?: boolean; +} + +/** + * Central registry of all supported licenses. + * This is the single source of truth - all other license mappings derive from this. + * + * Includes: + * - CC 4.0 licenses (current) + * - CC 3.0 licenses (legacy support) + * - CC 2.5 licenses (legacy support) + * - GNU/GPL licenses + * - EUPL license + * - GFDL license + * - Other license types + */ +export const LICENSE_REGISTRY: Record = { + // === Creative Commons 4.0 (Current) === + 'creative commons: attribution 4.0': { + displayName: 'creative commons: attribution 4.0 (BY)', + url: 'https://creativecommons.org/licenses/by/4.0/', + cssClass: 'cc', + }, + 'creative commons: attribution - share alike 4.0': { + displayName: 'creative commons: attribution - share alike 4.0 (BY-SA)', + url: 'https://creativecommons.org/licenses/by-sa/4.0/', + cssClass: 'cc cc-by-sa', + }, + 'creative commons: attribution - non derived work 4.0': { + displayName: 'creative commons: attribution - non derived work 4.0 (BY-ND)', + url: 'https://creativecommons.org/licenses/by-nd/4.0/', + cssClass: 'cc cc-by-nd', + }, + 'creative commons: attribution - non commercial 4.0': { + displayName: 'creative commons: attribution - non commercial 4.0 (BY-NC)', + url: 'https://creativecommons.org/licenses/by-nc/4.0/', + cssClass: 'cc cc-by-nc', + }, + 'creative commons: attribution - non commercial - share alike 4.0': { + displayName: 'creative commons: attribution - non commercial - share alike 4.0 (BY-NC-SA)', + url: 'https://creativecommons.org/licenses/by-nc-sa/4.0/', + cssClass: 'cc cc-by-nc-sa', + }, + 'creative commons: attribution - non derived work - non commercial 4.0': { + displayName: 'creative commons: attribution - non derived work - non commercial 4.0 (BY-NC-ND)', + url: 'https://creativecommons.org/licenses/by-nc-nd/4.0/', + cssClass: 'cc cc-by-nc-nd', + }, + + // === Creative Commons 3.0 (Legacy - not selectable in dropdown) === + 'creative commons: attribution 3.0': { + displayName: 'creative commons: attribution 3.0 (BY)', + url: 'https://creativecommons.org/licenses/by/3.0/', + cssClass: 'cc', + legacy: true, + }, + 'creative commons: attribution - share alike 3.0': { + displayName: 'creative commons: attribution - share alike 3.0 (BY-SA)', + url: 'https://creativecommons.org/licenses/by-sa/3.0/', + cssClass: 'cc cc-by-sa', + legacy: true, + }, + 'creative commons: attribution - non derived work 3.0': { + displayName: 'creative commons: attribution - non derived work 3.0 (BY-ND)', + url: 'https://creativecommons.org/licenses/by-nd/3.0/', + cssClass: 'cc cc-by-nd', + legacy: true, + }, + 'creative commons: attribution - non commercial 3.0': { + displayName: 'creative commons: attribution - non commercial 3.0 (BY-NC)', + url: 'https://creativecommons.org/licenses/by-nc/3.0/', + cssClass: 'cc cc-by-nc', + legacy: true, + }, + 'creative commons: attribution - non commercial - share alike 3.0': { + displayName: 'creative commons: attribution - non commercial - share alike 3.0 (BY-NC-SA)', + url: 'https://creativecommons.org/licenses/by-nc-sa/3.0/', + cssClass: 'cc cc-by-nc-sa', + legacy: true, + }, + 'creative commons: attribution - non derived work - non commercial 3.0': { + displayName: 'creative commons: attribution - non derived work - non commercial 3.0 (BY-NC-ND)', + url: 'https://creativecommons.org/licenses/by-nc-nd/3.0/', + cssClass: 'cc cc-by-nc-nd', + legacy: true, + }, + + // === Creative Commons 2.5 (Legacy - not selectable in dropdown) === + 'creative commons: attribution 2.5': { + displayName: 'creative commons: attribution 2.5 (BY)', + url: 'https://creativecommons.org/licenses/by/2.5/', + cssClass: 'cc', + legacy: true, + }, + 'creative commons: attribution - share alike 2.5': { + displayName: 'creative commons: attribution - share alike 2.5 (BY-SA)', + url: 'https://creativecommons.org/licenses/by-sa/2.5/', + cssClass: 'cc cc-by-sa', + legacy: true, + }, + 'creative commons: attribution - non derived work 2.5': { + displayName: 'creative commons: attribution - non derived work 2.5 (BY-ND)', + url: 'https://creativecommons.org/licenses/by-nd/2.5/', + cssClass: 'cc cc-by-nd', + legacy: true, + }, + 'creative commons: attribution - non commercial 2.5': { + displayName: 'creative commons: attribution - non commercial 2.5 (BY-NC)', + url: 'https://creativecommons.org/licenses/by-nc/2.5/', + cssClass: 'cc cc-by-nc', + legacy: true, + }, + 'creative commons: attribution - non commercial - share alike 2.5': { + displayName: 'creative commons: attribution - non commercial - share alike 2.5 (BY-NC-SA)', + url: 'https://creativecommons.org/licenses/by-nc-sa/2.5/', + cssClass: 'cc cc-by-nc-sa', + legacy: true, + }, + 'creative commons: attribution - non derived work - non commercial 2.5': { + displayName: 'creative commons: attribution - non derived work - non commercial 2.5 (BY-NC-ND)', + url: 'https://creativecommons.org/licenses/by-nc-nd/2.5/', + cssClass: 'cc cc-by-nc-nd', + legacy: true, + }, + + // === Public Domain === + 'public domain': { + displayName: 'public domain', + url: 'https://creativecommons.org/publicdomain/zero/1.0/', + cssClass: 'cc cc-0', + }, + + // === GNU/GPL Licenses (Legacy - not selectable in dropdown, no icon in themes) === + 'gnu/gpl': { + displayName: 'gnu/gpl', + url: 'https://www.gnu.org/licenses/gpl.html', + cssClass: '', + legacy: true, + }, + 'free software license gpl': { + displayName: 'free software license GPL', + url: '', + cssClass: '', + legacy: true, + }, + + // === EUPL License (Legacy - not selectable in dropdown, no icon in themes) === + 'free software license eupl': { + displayName: 'free software license EUPL', + url: '', + cssClass: '', + legacy: true, + }, + + // === Dual License GPL + EUPL (Legacy - not selectable in dropdown, no icon in themes) === + 'dual free content license gpl and eupl': { + displayName: 'dual free content license GPL and EUPL', + url: '', + cssClass: '', + legacy: true, + }, + + // === GFDL License (Legacy - not selectable in dropdown, no icon in themes) === + 'license gfdl': { + displayName: 'license GFDL', + url: '', + cssClass: '', + legacy: true, + }, + + // === Other Licenses (Legacy - not selectable in dropdown) === + 'other free software licenses': { + displayName: 'other free software licenses', + url: '', + cssClass: '', + legacy: true, + }, + 'propietary license': { + displayName: 'propietary license', + url: '', + cssClass: '', + hideInFooter: true, + }, + 'intellectual property license': { + displayName: 'intellectual property license', + url: '', + cssClass: '', + legacy: true, + }, + 'not appropriate': { + displayName: 'not appropriate', + url: '', + cssClass: '', + hideInFooter: true, + }, }; +// ============================================================================= +// License CSS Class Lookup +// ============================================================================= + /** - * Get CSS class for a given license name - * @param licenseName - The license name - * @returns The CSS class/es for the license icon + * Get CSS class for license icon display. + * Looks up the cssClass from LICENSE_REGISTRY. + * + * @param licenseName - License name to look up + * @returns The CSS class(es) for the license icon (empty string if no icon) */ export function getLicenseClass(licenseName: string): string { - if (!licenseName) return 'cc cc-by-sa'; + if (!licenseName) { + return ''; + } const cleanName = licenseName.toLowerCase().trim().replace(/\s+/g, ' '); - // 1. Direct lookup - if (LICENSE_CLASS_MAP[cleanName]) { - return LICENSE_CLASS_MAP[cleanName]; - } - - // 2. Fallback: check for keywords (order matters: most specific first) - if (cleanName.includes('by-nc-nd') || (cleanName.includes('non derived') && cleanName.includes('non commercial'))) { - return 'cc cc-by-nc-nd'; - } - if (cleanName.includes('by-nc-sa') || (cleanName.includes('non commercial') && cleanName.includes('share alike'))) { - return 'cc cc-by-nc-sa'; - } - if (cleanName.includes('by-nc') || cleanName.includes('non commercial')) { - return 'cc cc-by-nc'; - } - if (cleanName.includes('by-nd') || cleanName.includes('non derived')) { - return 'cc cc-by-nd'; - } - if (cleanName.includes('by-sa') || cleanName.includes('share alike')) { - return 'cc cc-by-sa'; - } - if (cleanName.includes('public domain') || cleanName.includes('cc0')) { - return 'cc cc-0'; - } - // CC-BY (attribution only) or full names containing these keywords - if (cleanName.includes('creative commons') || cleanName.includes('attribution') || cleanName === 'cc-by') { - return 'cc'; + // Direct lookup in registry + if (LICENSE_REGISTRY[cleanName]) { + return LICENSE_REGISTRY[cleanName].cssClass; } - return 'cc cc-by-sa'; + return ''; } -/** - * License URL map - maps license names to their URLs - */ -const LICENSE_URL_MAP: Record = { - 'gnu/gpl': 'https://www.gnu.org/licenses/gpl.html', - 'creative commons: attribution 4.0': 'https://creativecommons.org/licenses/by/4.0/', - 'creative commons: attribution - share alike 4.0': 'https://creativecommons.org/licenses/by-sa/4.0/', - 'creative commons: attribution - non derived work 4.0': 'https://creativecommons.org/licenses/by-nd/4.0/', - 'creative commons: attribution - non commercial 4.0': 'https://creativecommons.org/licenses/by-nc/4.0/', - 'creative commons: attribution - non commercial - share alike 4.0': - 'https://creativecommons.org/licenses/by-nc-sa/4.0/', - 'creative commons: attribution - non derived work - non commercial 4.0': - 'https://creativecommons.org/licenses/by-nc-nd/4.0/', - 'public domain': 'https://creativecommons.org/publicdomain/zero/1.0/', -}; - /** * Get URL for a given license name * @param licenseName - The license name - * @returns The URL for the license + * @returns The URL for the license (empty string if no URL available) */ export function getLicenseUrl(licenseName: string): string { - if (!licenseName) return 'https://creativecommons.org/licenses/by-sa/4.0/'; + // Empty license = no license specified (legacy content with unknown license) + if (!licenseName) return ''; const cleanName = licenseName.toLowerCase().trim().replace(/\s+/g, ' '); - // 1. Direct lookup - if (LICENSE_URL_MAP[cleanName]) { - return LICENSE_URL_MAP[cleanName]; + // 1. Direct lookup in registry + if (LICENSE_REGISTRY[cleanName]) { + return LICENSE_REGISTRY[cleanName].url; } // 2. Fallback: check for keywords (order matters: most specific first) + // For CC licenses with version numbers, extract version and build URL + const versionMatch = cleanName.match(/(\d+\.\d+|\d+)$/); + const version = versionMatch ? versionMatch[1] : '4.0'; + if (cleanName.includes('by-nc-nd') || (cleanName.includes('non derived') && cleanName.includes('non commercial'))) { - return 'https://creativecommons.org/licenses/by-nc-nd/4.0/'; + return `https://creativecommons.org/licenses/by-nc-nd/${version}/`; } if (cleanName.includes('by-nc-sa') || (cleanName.includes('non commercial') && cleanName.includes('share alike'))) { - return 'https://creativecommons.org/licenses/by-nc-sa/4.0/'; + return `https://creativecommons.org/licenses/by-nc-sa/${version}/`; } if (cleanName.includes('by-nc') || cleanName.includes('non commercial')) { - return 'https://creativecommons.org/licenses/by-nc/4.0/'; + return `https://creativecommons.org/licenses/by-nc/${version}/`; } if (cleanName.includes('by-nd') || cleanName.includes('non derived')) { - return 'https://creativecommons.org/licenses/by-nd/4.0/'; + return `https://creativecommons.org/licenses/by-nd/${version}/`; } if (cleanName.includes('by-sa') || cleanName.includes('share alike')) { - return 'https://creativecommons.org/licenses/by-sa/4.0/'; + return `https://creativecommons.org/licenses/by-sa/${version}/`; } if (cleanName.includes('public domain') || cleanName.includes('cc0')) { return 'https://creativecommons.org/publicdomain/zero/1.0/'; @@ -499,18 +668,34 @@ export function getLicenseUrl(licenseName: string): string { return 'https://www.gnu.org/licenses/gpl.html'; } if (cleanName.includes('creative commons') || cleanName.includes('attribution')) { - return 'https://creativecommons.org/licenses/by/4.0/'; + return `https://creativecommons.org/licenses/by/${version}/`; + } + + // For licenses without URLs (EUPL, GFDL, IP, etc.), return empty string + // Check if it matches a known license type without URL + if ( + cleanName.includes('eupl') || + cleanName.includes('gfdl') || + cleanName.includes('intellectual property') || + cleanName.includes('propietary') || + cleanName.includes('proprietary') || + cleanName.includes('not appropriate') || + cleanName.includes('other free software') + ) { + return ''; } + // Default fallback for unknown CC-like licenses return 'https://creativecommons.org/licenses/by-sa/4.0/'; } /** * Map of short license codes to full display text * This normalizes various license codes to the canonical display format + * Derived from LICENSE_REGISTRY with additional short code mappings */ const LICENSE_DISPLAY_MAP: Record = { - // Short codes + // Short codes (always map to 4.0 versions) 'cc-by': 'creative commons: attribution 4.0', 'cc-by-sa': 'creative commons: attribution - share alike 4.0', 'cc-by-nd': 'creative commons: attribution - non derived work 4.0', @@ -519,17 +704,8 @@ const LICENSE_DISPLAY_MAP: Record = { 'cc-by-nc-nd': 'creative commons: attribution - non derived work - non commercial 4.0', 'cc0': 'public domain', 'cc-0': 'public domain', - // Already full names (for case normalization) - 'creative commons: attribution 4.0': 'creative commons: attribution 4.0', - 'creative commons: attribution - share alike 4.0': 'creative commons: attribution - share alike 4.0', - 'creative commons: attribution - non derived work 4.0': 'creative commons: attribution - non derived work 4.0', - 'creative commons: attribution - non commercial 4.0': 'creative commons: attribution - non commercial 4.0', - 'creative commons: attribution - non commercial - share alike 4.0': - 'creative commons: attribution - non commercial - share alike 4.0', - 'creative commons: attribution - non derived work - non commercial 4.0': - 'creative commons: attribution - non derived work - non commercial 4.0', - 'public domain': 'public domain', - 'propietary license': 'propietary license', + // Full names from registry + ...Object.fromEntries(Object.entries(LICENSE_REGISTRY).map(([key, entry]) => [key, entry.displayName])), }; /** @@ -540,43 +716,93 @@ const LICENSE_DISPLAY_MAP: Record = { * @returns Formatted license text for display */ export function formatLicenseText(licenseName: string): string { - if (!licenseName) return 'creative commons: attribution - share alike 4.0'; + // Empty license = no license specified (legacy content with unknown license) + if (!licenseName) return ''; const cleaned = licenseName.toLowerCase().trim(); - // Direct lookup in display map + // Direct lookup in registry + if (LICENSE_REGISTRY[cleaned]) { + return LICENSE_REGISTRY[cleaned].displayName; + } + + // Direct lookup in display map (for short codes) if (LICENSE_DISPLAY_MAP[cleaned]) { return LICENSE_DISPLAY_MAP[cleaned]; } // Fallback: try to match by keywords for partial matches + // Extract version if present, default to 4.0 + const versionMatch = cleaned.match(/(\d+\.\d+|\d+)$/); + const version = versionMatch ? versionMatch[1] : '4.0'; + if (cleaned.includes('by-nc-nd') || (cleaned.includes('non derived') && cleaned.includes('non commercial'))) { - return 'creative commons: attribution - non derived work - non commercial 4.0'; + return `creative commons: attribution - non derived work - non commercial ${version}`; } if (cleaned.includes('by-nc-sa') || (cleaned.includes('non commercial') && cleaned.includes('share alike'))) { - return 'creative commons: attribution - non commercial - share alike 4.0'; + return `creative commons: attribution - non commercial - share alike ${version}`; } if (cleaned.includes('by-nc') || cleaned.includes('non commercial')) { - return 'creative commons: attribution - non commercial 4.0'; + return `creative commons: attribution - non commercial ${version}`; } if (cleaned.includes('by-nd') || cleaned.includes('non derived')) { - return 'creative commons: attribution - non derived work 4.0'; + return `creative commons: attribution - non derived work ${version}`; } if (cleaned.includes('by-sa') || cleaned.includes('share alike')) { - return 'creative commons: attribution - share alike 4.0'; + return `creative commons: attribution - share alike ${version}`; } if (cleaned.includes('public domain') || cleaned.includes('cc0') || cleaned.includes('cc-0')) { return 'public domain'; } - if (cleaned.includes('propietary')) { + if (cleaned.includes('gfdl')) { + return 'license GFDL'; + } + if (cleaned.includes('eupl') && cleaned.includes('gpl')) { + return 'dual free content license GPL and EUPL'; + } + if (cleaned.includes('eupl')) { + return 'free software license EUPL'; + } + if (cleaned.includes('gpl') || cleaned.includes('gnu')) { + return 'gnu/gpl'; + } + if (cleaned.includes('intellectual property')) { + return 'intellectual property license'; + } + if (cleaned.includes('propietary') || cleaned.includes('proprietary')) { return 'propietary license'; } + if (cleaned.includes('not appropriate')) { + return 'not appropriate'; + } + if (cleaned.includes('other free software')) { + return 'other free software licenses'; + } if (cleaned.includes('creative commons') || cleaned.includes('attribution') || cleaned.includes('cc-by')) { - return 'creative commons: attribution 4.0'; + return `creative commons: attribution ${version}`; } - // Default fallback - return 'creative commons: attribution - share alike 4.0'; + // Unknown license - return as-is (no default) + return licenseName; +} + +/** + * Check if a license should show a footer in exports. + * Returns false for empty license or licenses with hideInFooter: true in the registry. + * + * @param licenseName - The license name from metadata + * @returns true if footer should be shown, false otherwise + */ +export function shouldShowLicenseFooter(licenseName: string): boolean { + if (!licenseName) return false; + + const cleaned = licenseName.toLowerCase().trim().replace(/\s+/g, ' '); + const entry = LICENSE_REGISTRY[cleaned]; + + // If license is in registry and has hideInFooter, don't show footer + if (entry?.hideInFooter) return false; + + return true; } // ============================================================================= diff --git a/src/shared/export/exporters/Epub3Exporter.ts b/src/shared/export/exporters/Epub3Exporter.ts index 7305c08ae..74ef88adf 100644 --- a/src/shared/export/exporters/Epub3Exporter.ts +++ b/src/shared/export/exporters/Epub3Exporter.ts @@ -590,9 +590,9 @@ export class Epub3Exporter extends BaseExporter { isIndex, usedIdevices, author: meta.author || '', - license: meta.license || 'CC-BY-SA', + license: meta.license || '', description: meta.description || '', - licenseUrl: meta.licenseUrl || 'https://creativecommons.org/licenses/by-sa/4.0/', + licenseUrl: meta.licenseUrl || '', bodyClass, // Theme files for HTML head includes themeFiles: themeFiles || [], diff --git a/src/shared/export/exporters/Html5Exporter.ts b/src/shared/export/exporters/Html5Exporter.ts index cae672467..91b8605cf 100644 --- a/src/shared/export/exporters/Html5Exporter.ts +++ b/src/shared/export/exporters/Html5Exporter.ts @@ -415,9 +415,9 @@ export class Html5Exporter extends BaseExporter { isIndex, usedIdevices, author: meta.author || '', - license: meta.license || 'creative commons: attribution - share alike 4.0', + license: meta.license || '', description: meta.description || '', - licenseUrl: meta.licenseUrl || 'https://creativecommons.org/licenses/by-sa/4.0/', + licenseUrl: meta.licenseUrl || '', // Page counter options totalPages: allPages.length, currentPageIndex, @@ -740,7 +740,7 @@ export class Html5Exporter extends BaseExporter { // 10. Add project assets await this.addAssetsToPreviewFiles(files, fileList); - // 11. Generate ELPX manifest file if download-source-file is used + // 11. Generate ELPX manifest file and ensure required libraries if download-source-file is used if (needsElpxDownload && fileList) { for (const [htmlFile] of pageHtmlMap) { if (!fileList.includes(htmlFile)) { @@ -749,6 +749,20 @@ export class Html5Exporter extends BaseExporter { } const manifestJs = this.generateElpxManifestFile(fileList); addFile('libs/elpx-manifest.js', manifestJs); + + // Ensure ELPX download libraries are present (may not be detected by library detector) + const elpxLibFiles = ['fflate/fflate.umd.js', 'exe_elpx_download/exe_elpx_download.js']; + const missingLibs = elpxLibFiles.filter(f => !files.has(`libs/${f}`)); + if (missingLibs.length > 0) { + try { + const libContents = await this.resources.fetchLibraryFiles(missingLibs); + for (const [libPath, content] of libContents) { + addFile(`libs/${libPath}`, content); + } + } catch { + // Library files not available - continue anyway + } + } } // 12. Add all HTML pages @@ -758,11 +772,17 @@ export class Html5Exporter extends BaseExporter { const filename = i === 0 ? 'index.html' : `html/${uniqueFilename}`; let html = pageHtmlMap.get(filename) || ''; - // Only add manifest script to pages that have download-source-file iDevice + // Only add ELPX download scripts to pages that have download-source-file iDevice or exe-package:elp links if (needsElpxDownload && this.pageHasDownloadSourceFile(page)) { const basePath = i === 0 ? '' : '../'; + // Library scripts must be loaded before the manifest script + const fflateScript = ``; + const elpxDownloadScript = ``; const manifestScriptTag = ``; - html = html.replace(/<\/body>/i, `${manifestScriptTag}\n`); + html = html.replace( + /<\/body>/i, + `${fflateScript}\n${elpxDownloadScript}\n${manifestScriptTag}\n`, + ); } const encoder = new TextEncoder(); files.set(filename, encoder.encode(html)); diff --git a/src/shared/export/exporters/ImsExporter.ts b/src/shared/export/exporters/ImsExporter.ts index d2c5b6035..1285a27a5 100644 --- a/src/shared/export/exporters/ImsExporter.ts +++ b/src/shared/export/exporters/ImsExporter.ts @@ -252,9 +252,9 @@ export class ImsExporter extends Html5Exporter { isIndex, usedIdevices, author: meta.author || '', - license: meta.license || 'CC-BY-SA', + license: meta.license || '', description: meta.description || '', - licenseUrl: meta.licenseUrl || 'https://creativecommons.org/licenses/by-sa/4.0/', + licenseUrl: meta.licenseUrl || '', // Export options - IMS specific overrides // IMS exports don't use client-side search - LMS handles navigation addSearchBox: false, diff --git a/src/shared/export/exporters/PageExporter.ts b/src/shared/export/exporters/PageExporter.ts index f4cf2424f..e5a572504 100644 --- a/src/shared/export/exporters/PageExporter.ts +++ b/src/shared/export/exporters/PageExporter.ts @@ -134,7 +134,7 @@ export class PageExporter extends Html5Exporter { customStyles: meta.customStyles || '', usedIdevices, author: meta.author || '', - license: meta.license || 'CC-BY-SA', + license: meta.license || '', faviconPath: faviconInfo?.path, faviconType: faviconInfo?.type, }); diff --git a/src/shared/export/exporters/PrintPreviewExporter.ts b/src/shared/export/exporters/PrintPreviewExporter.ts index 84cf6ba24..02c52ddb8 100644 --- a/src/shared/export/exporters/PrintPreviewExporter.ts +++ b/src/shared/export/exporters/PrintPreviewExporter.ts @@ -19,7 +19,7 @@ import type { MermaidPreRenderResult, } from '../interfaces'; import { IdeviceRenderer } from '../renderers/IdeviceRenderer'; -import { normalizeIdeviceType } from '../constants'; +import { normalizeIdeviceType, shouldShowLicenseFooter } from '../constants'; import { LibraryDetector } from '../utils/LibraryDetector'; import { getIdeviceExportFiles } from '../../../services/idevice-config'; @@ -215,7 +215,7 @@ export class PrintPreviewExporter { const lang = meta.language || 'en'; const projectTitle = meta.title || 'eXeLearning'; const customStyles = meta.customStyles || ''; - const license = meta.license || 'CC-BY-SA'; + const license = meta.license || ''; const themeName = meta.theme || 'base'; const userFooterContent = meta.footer || ''; @@ -404,6 +404,11 @@ ${blockHtml} userFooterHtml = `
${userFooterContent}
`; } + // Skip license section for empty, "propietary license", and "not appropriate" + if (!shouldShowLicenseFooter(license)) { + return `
${userFooterHtml}
`; + } + return ``; diff --git a/src/shared/export/exporters/Scorm12Exporter.ts b/src/shared/export/exporters/Scorm12Exporter.ts index cde65ae15..19464b947 100644 --- a/src/shared/export/exporters/Scorm12Exporter.ts +++ b/src/shared/export/exporters/Scorm12Exporter.ts @@ -285,9 +285,9 @@ export class Scorm12Exporter extends Html5Exporter { isIndex, usedIdevices, author: meta.author || '', - license: meta.license || 'CC-BY-SA', + license: meta.license || '', description: meta.description || '', - licenseUrl: meta.licenseUrl || 'https://creativecommons.org/licenses/by-sa/4.0/', + licenseUrl: meta.licenseUrl || '', // Export options - SCORM specific overrides // SCORM/IMS exports don't use client-side search - LMS handles navigation addSearchBox: false, diff --git a/src/shared/export/exporters/Scorm2004Exporter.ts b/src/shared/export/exporters/Scorm2004Exporter.ts index 73853a976..80e1b44a7 100644 --- a/src/shared/export/exporters/Scorm2004Exporter.ts +++ b/src/shared/export/exporters/Scorm2004Exporter.ts @@ -285,9 +285,9 @@ export class Scorm2004Exporter extends Html5Exporter { isIndex, usedIdevices, author: meta.author || '', - license: meta.license || 'CC-BY-SA', + license: meta.license || '', description: meta.description || '', - licenseUrl: meta.licenseUrl || 'https://creativecommons.org/licenses/by-sa/4.0/', + licenseUrl: meta.licenseUrl || '', // Export options - SCORM specific overrides // SCORM/IMS exports don't use client-side search - LMS handles navigation addSearchBox: false, diff --git a/src/shared/export/renderers/PageRenderer.spec.ts b/src/shared/export/renderers/PageRenderer.spec.ts index 6851e3780..c329d7ef0 100644 --- a/src/shared/export/renderers/PageRenderer.spec.ts +++ b/src/shared/export/renderers/PageRenderer.spec.ts @@ -547,6 +547,7 @@ describe('PageRenderer', () => { }); it('should render correct license class for different licenses', () => { + // getLicenseClass looks up cssClass from LICENSE_REGISTRY by license name const licenses = [ { name: 'creative commons: attribution 4.0', class: 'cc' }, { name: 'creative commons: attribution - share alike 4.0', class: 'cc cc-by-sa' }, @@ -558,11 +559,6 @@ describe('PageRenderer', () => { class: 'cc cc-by-nc-nd', }, { name: 'public domain', class: 'cc cc-0' }, - { name: 'propietary license', class: 'propietary' }, - // Robustness tests - { name: ' creative commons: attribution - non commercial 4.0 ', class: 'cc cc-by-nc' }, // Extra spaces - { name: 'CREATIVE COMMONS: ATTRIBUTION - NON COMMERCIAL 4.0', class: 'cc cc-by-nc' }, // Case insensitivity - { name: 'Some other license text containing by-nc-sa', class: 'cc cc-by-nc-sa' }, // Partial match ]; for (const lic of licenses) { @@ -574,6 +570,28 @@ describe('PageRenderer', () => { } }); + it('should skip license section for propietary license (no footer)', () => { + const html = renderer.renderFooterSection({ + license: 'propietary license', + licenseUrl: 'https://example.com', + }); + + expect(html).toContain('id="siteFooter"'); + expect(html).not.toContain('id="packageLicense"'); + expect(html).not.toContain('class="license"'); + }); + + it('should skip license section for not appropriate (no footer)', () => { + const html = renderer.renderFooterSection({ + license: 'not appropriate', + licenseUrl: 'https://example.com', + }); + + expect(html).toContain('id="siteFooter"'); + expect(html).not.toContain('id="packageLicense"'); + expect(html).not.toContain('class="license"'); + }); + it('should include user footer content when provided', () => { const html = renderer.renderFooterSection({ license: 'CC', @@ -591,6 +609,34 @@ describe('PageRenderer', () => { expect(html).not.toContain('id="siteUserFooter"'); }); + + it('should skip license section when license is empty (legacy content)', () => { + const html = renderer.renderFooterSection({ + license: '', + }); + + // Footer should still render but without license content + expect(html).toContain('id="siteFooter"'); + expect(html).toContain('id="siteFooterContent"'); + // License section should not be rendered + expect(html).not.toContain('id="packageLicense"'); + expect(html).not.toContain('class="license"'); + expect(html).not.toContain('license-label'); + }); + + it('should render user footer content even when license is empty', () => { + const html = renderer.renderFooterSection({ + license: '', + userFooterContent: '

My custom footer

', + }); + + // Footer should render with user content + expect(html).toContain('id="siteFooter"'); + expect(html).toContain('id="siteUserFooter"'); + expect(html).toContain('My custom footer'); + // But no license section + expect(html).not.toContain('id="packageLicense"'); + }); }); describe('renderMadeWithEXe', () => { @@ -817,6 +863,49 @@ describe('PageRenderer', () => { expect(html).toContain('CC-BY-SA'); expect(html).toContain('creativecommons.org/licenses/by-sa/4.0/'); }); + + it('should return empty string when license is empty (legacy content)', () => { + const html = renderer.renderFooter({ + author: 'Test Author', + license: '', + }); + + // Empty license should return empty string + expect(html).toBe(''); + }); + }); + + describe('renderLicense (deprecated)', () => { + it('should render license div when license is provided', () => { + const html = renderer.renderLicense({ + author: 'Test Author', + license: 'CC-BY', + licenseUrl: 'https://example.com/license', + }); + + expect(html).toContain('id="packageLicense"'); + expect(html).toContain('CC-BY'); + expect(html).toContain('href="https://example.com/license"'); + }); + + it('should return empty string when license is empty (legacy content)', () => { + const html = renderer.renderLicense({ + author: 'Test Author', + license: '', + }); + + expect(html).toBe(''); + }); + + it('should render empty href when licenseUrl not provided', () => { + const html = renderer.renderLicense({ + author: 'Test Author', + license: 'CC-BY-SA', + }); + + // No default URL - href should be empty when not provided + expect(html).toContain('href=""'); + }); }); describe('generateSearchData', () => { diff --git a/src/shared/export/renderers/PageRenderer.ts b/src/shared/export/renderers/PageRenderer.ts index bf97c2624..1dc31de47 100644 --- a/src/shared/export/renderers/PageRenderer.ts +++ b/src/shared/export/renderers/PageRenderer.ts @@ -16,7 +16,7 @@ import type { ExportPage, PageRenderOptions } from '../interfaces'; import { IdeviceRenderer } from './IdeviceRenderer'; -import { LIBRARY_PATTERNS, getLicenseClass, formatLicenseText } from '../constants'; +import { LIBRARY_PATTERNS, getLicenseClass, formatLicenseText, shouldShowLicenseFooter } from '../constants'; /** * PageRenderer class @@ -61,9 +61,9 @@ export class PageRenderer { basePath = '', isIndex = false, usedIdevices = [], - license = 'creative commons: attribution - share alike 4.0', + license = '', description = '', - licenseUrl = 'https://creativecommons.org/licenses/by-sa/4.0/', + licenseUrl = '', // Page counter options totalPages, currentPageIndex, @@ -192,7 +192,7 @@ ${madeWithExeHtml} extraHeadScripts = '', isScorm: _isScorm = false, description = '', - licenseUrl = 'https://creativecommons.org/licenses/by-sa/4.0/', + licenseUrl = '', addAccessibilityToolbar = false, addMathJax = false, extraHeadContent = '', @@ -207,8 +207,7 @@ ${madeWithExeHtml} let head = ` - -${this.escapeHtml(pageTitle)}`; +${licenseUrl ? `\n` : ''}${this.escapeHtml(pageTitle)}`; // Favicon head += `\n${this.renderFavicon(basePath, faviconPath, faviconType)}`; @@ -712,16 +711,25 @@ ${madeWithExeHtml} * @returns Footer HTML with siteFooter wrapper */ renderFooterSection(options: { license: string; licenseUrl?: string; userFooterContent?: string }): string { - const { license, licenseUrl = 'https://creativecommons.org/licenses/by-sa/4.0/', userFooterContent } = options; + const { license, licenseUrl = '', userFooterContent } = options; let userFooterHtml = ''; if (userFooterContent) { userFooterHtml = `
${userFooterContent}
\n
`; } + // Skip license section for: + // - Empty license (no license specified, legacy content with unknown license) + // - "propietary license" and "not appropriate" (no meaningful license to display) + if (!shouldShowLicenseFooter(license)) { + return `
${userFooterHtml}
`; + } + const licenseText = formatLicenseText(license); + const licenseClass = getLicenseClass(license); + const effectiveLicenseUrl = licenseUrl; - return ``; * @deprecated Use renderFooterSection instead */ renderLicense(options: { author: string; license: string; licenseUrl?: string }): string { - const { license, licenseUrl = 'https://creativecommons.org/licenses/by-sa/4.0/' } = options; + const { license, licenseUrl = '' } = options; + + // Skip license for empty, "propietary license", and "not appropriate" + if (!shouldShowLicenseFooter(license)) { + return ''; + } + + const effectiveLicenseUrl = licenseUrl; return `
-

Licensed under the ${this.escapeHtml(license)}

+

Licensed under the ${this.escapeHtml(license)}

`; } @@ -868,8 +883,8 @@ ${userFooterHtml}`; language = 'en', customStyles = '', usedIdevices = [], - license = 'creative commons: attribution - share alike 4.0', - licenseUrl = 'https://creativecommons.org/licenses/by-sa/4.0/', + license = '', + licenseUrl = '', faviconPath = 'libs/favicon.ico', faviconType = 'image/x-icon', addExeLink = true, diff --git a/test/e2e/playwright/helpers/workarea-helpers.ts b/test/e2e/playwright/helpers/workarea-helpers.ts index 71563d63f..fdc16cd91 100644 --- a/test/e2e/playwright/helpers/workarea-helpers.ts +++ b/test/e2e/playwright/helpers/workarea-helpers.ts @@ -1166,6 +1166,9 @@ export async function exportPage(page: Page, nodeId: string): Promise * @param page - Playwright page */ export async function addTextIdevice(page: Page): Promise { + // Ensure a non-root page is selected first (like addIdevice does) + await selectFirstPage(page); + // Expand "Information and presentation" category const infoCategory = page .locator('.idevice_category') @@ -1308,8 +1311,10 @@ export async function changeBlockIcon(page: Page, blockIndex: number, iconIndex: const iconBtn = block.locator('header.box-head button.box-icon').first(); await iconBtn.click(); - // 2. Wait for icon picker modal - await page.waitForSelector('.option-block-icon', { timeout: 10000 }); + // 2. Wait for icon picker modal to be shown and icons to be loaded + await page.locator('.modal.show').waitFor({ state: 'visible', timeout: 10000 }); + // Wait for icons to be attached (using waitForSelector which waits for DOM attachment by default) + await page.waitForSelector('.option-block-icon', { state: 'attached', timeout: 10000 }); // 3. Verify the icon at the requested index exists const iconCount = await page.locator('.option-block-icon').count(); @@ -1332,9 +1337,37 @@ export async function changeBlockIcon(page: Page, blockIndex: number, iconIndex: const saveBtn = page.locator('.modal.show button.btn.button-primary').first(); await saveBtn.click(); - // 6. Wait for modal to close - await page.waitForFunction(() => !document.querySelector('.modal.show .option-block-icon'), { timeout: 5000 }); - await page.waitForTimeout(500); + // 6. Wait for modal to close completely + await page.waitForFunction(() => !document.querySelector('.modal.show'), { timeout: 5000 }); + // Small delay to ensure Bootstrap modal transition completes + await page.waitForTimeout(300); + + // 7. Wait for icon to be fully rendered in the DOM + if (iconIndex === 0) { + // Wait for empty icon state (SVG placeholder) + await page.waitForFunction( + idx => { + const block = document.querySelectorAll('#node-content article.box')[idx] as HTMLElement; + if (!block) return false; + const iconBtn = block.querySelector('header.box-head button.box-icon'); + return iconBtn?.classList.contains('exe-no-icon') || iconBtn?.querySelector('svg') !== null; + }, + blockIndex, + { timeout: 5000 }, + ); + } else { + // Wait for icon image to be loaded + await page.waitForFunction( + idx => { + const block = document.querySelectorAll('#node-content article.box')[idx] as HTMLElement; + if (!block) return false; + const img = block.querySelector('header.box-head button.box-icon img') as HTMLImageElement; + return img?.complete && img.naturalWidth > 0; + }, + blockIndex, + { timeout: 5000 }, + ); + } } /** diff --git a/test/e2e/playwright/specs/cloning.spec.ts b/test/e2e/playwright/specs/cloning.spec.ts index 7b674f83b..bcd887526 100644 --- a/test/e2e/playwright/specs/cloning.spec.ts +++ b/test/e2e/playwright/specs/cloning.spec.ts @@ -121,10 +121,9 @@ async function addTextIdeviceWithContent(page: Page, content: string): Promise { - const editor = (window as any).tinymce?.activeEditor; - return !!editor && editor.isDirty(); - }); + // Note: The isDirty() wait was removed because it's unreliable - the dirty flag may not + // propagate immediately with TinyMCE in "multiple-visible" mode with Yjs bindings. + // The subsequent wait for save completion and content rendering (lines 136-162) is sufficient. // Save the iDevice const saveBtn = textIdeviceNode.locator('.btn-save-idevice'); diff --git a/test/e2e/playwright/specs/collaborative/file-manager.spec.ts b/test/e2e/playwright/specs/collaborative/file-manager.spec.ts index eaf4a13a6..72f21d904 100644 --- a/test/e2e/playwright/specs/collaborative/file-manager.spec.ts +++ b/test/e2e/playwright/specs/collaborative/file-manager.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '../../fixtures/collaboration.fixture'; import { waitForYjsSync } from '../../helpers/sync-helpers'; import { waitForLoadingScreenHidden } from '../../fixtures/auth.fixture'; import type { Page } from '@playwright/test'; +import { addTextIdevice } from '../../helpers/workarea-helpers'; /** * Collaborative File Manager Tests @@ -10,85 +11,13 @@ import type { Page } from '@playwright/test'; * between multiple clients connected to the same project via WebSocket. */ -/** - * Helper to add a text iDevice and enter edit mode (needed to open File Manager) - */ -async function addTextIdeviceFromPanel(page: Page): Promise { - const pageNodeSelectors = [ - '.nav-element-text:has-text("New page")', - '.nav-element-text:has-text("Nueva página")', - '[data-testid="nav-node-text"]', - '.structure-tree li .nav-element-text', - ]; - - let pageSelected = false; - for (const selector of pageNodeSelectors) { - const element = page.locator(selector).first(); - if ((await element.count()) > 0) { - try { - await element.click({ force: true, timeout: 5000 }); - pageSelected = true; - break; - } catch { - // Try next selector - } - } - } - - if (!pageSelected) { - const treeItem = page.locator('#menu_structure .structure-tree li').first(); - if ((await treeItem.count()) > 0) { - await treeItem.click({ force: true }); - } - } - - await page.waitForTimeout(1000); - - await page - .waitForFunction( - () => { - const nodeContent = document.querySelector('#node-content'); - const metadata = document.querySelector('#properties-node-content-form'); - return nodeContent && (!metadata || !metadata.closest('.show')); - }, - { timeout: 10000 }, - ) - .catch(() => {}); - - const quickTextButton = page - .locator('[data-testid="quick-idevice-text"], .quick-idevice-btn[data-idevice="text"]') - .first(); - if ((await quickTextButton.count()) > 0 && (await quickTextButton.isVisible())) { - await quickTextButton.click(); - } else { - const infoCategory = page - .locator('#menu_idevices .accordion-item') - .filter({ hasText: /Information|Información/i }) - .locator('.accordion-button'); - - if ((await infoCategory.count()) > 0) { - const isCollapsed = await infoCategory.first().evaluate(el => el.classList.contains('collapsed')); - if (isCollapsed) { - await infoCategory.first().click(); - await page.waitForTimeout(500); - } - } - - const textIdevice = page.locator('.idevice_item[id="text"], [data-testid="idevice-text"]').first(); - await textIdevice.waitFor({ state: 'visible', timeout: 10000 }); - await textIdevice.click(); - } - - await page.locator('#node-content article .idevice_node.text').first().waitFor({ timeout: 15000 }); -} - /** * Helper to open the File Manager modal via TinyMCE image dialog */ async function openFileManager(page: Page): Promise { const existingTinyMce = page.locator('.tox-menubar'); if ((await existingTinyMce.count()) === 0) { - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); } await page.waitForSelector('.tox-menubar', { timeout: 15000 }); @@ -143,10 +72,12 @@ async function selectFirstFile(page: Page): Promise { await fileItem.waitFor({ state: 'visible', timeout: 10000 }); await fileItem.click({ force: true }); + // Wait for sidebar to be visible await page.waitForSelector('#modalFileManager .media-library-sidebar-content:not([style*="display: none"])', { timeout: 5000, }); + // Wait for item to be selected await page.waitForFunction( () => { const item = document.querySelector('#modalFileManager .media-library-item:not(.media-library-folder)'); @@ -156,6 +87,18 @@ async function selectFirstFile(page: Page): Promise { { timeout: 10000 }, ); + // Wait for sidebar content to be populated (filename element has content) + // This indicates showSidebarContent() has completed its async work including getImageDimensions() + await page.waitForFunction( + () => { + const filenameEl = document.querySelector('#modalFileManager .media-library-filename'); + return filenameEl?.textContent && filenameEl.textContent.trim().length > 0; + }, + null, + { timeout: 10000 }, + ); + + // Now the button should be enabled (updateButtonStates() was called at end of showSidebarContent()) await page.waitForFunction( () => { const renameBtn = document.querySelector( diff --git a/test/e2e/playwright/specs/collaborative/text.spec.ts b/test/e2e/playwright/specs/collaborative/text.spec.ts index 5a0064086..06d17188a 100644 --- a/test/e2e/playwright/specs/collaborative/text.spec.ts +++ b/test/e2e/playwright/specs/collaborative/text.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '../../fixtures/collaboration.fixture'; import { waitForYjsSync, waitForTextInContent } from '../../helpers/sync-helpers'; import { waitForLoadingScreenHidden } from '../../fixtures/auth.fixture'; import type { Page } from '@playwright/test'; +import { addTextIdevice, navigateToPageByTitle } from '../../helpers/workarea-helpers'; /** * Collaborative Text iDevice Tests @@ -23,82 +24,6 @@ async function waitForYjsBridge(page: Page): Promise { ); } -/** - * Helper to add a text iDevice from the panel - */ -async function addTextIdeviceFromPanel(page: Page): Promise { - // Select first PAGE node (not the root project node) - const pageNodeSelectors = [ - '.nav-element-text:has-text("New page")', - '.nav-element-text:has-text("Nueva página")', - '[data-testid="nav-node-text"]', - '.structure-tree li .nav-element-text', - ]; - - let pageSelected = false; - for (const selector of pageNodeSelectors) { - const element = page.locator(selector).first(); - if ((await element.count()) > 0) { - try { - await element.click({ force: true, timeout: 5000 }); - pageSelected = true; - break; - } catch { - // Try next selector - } - } - } - - if (!pageSelected) { - const treeItem = page.locator('#menu_structure .structure-tree li').first(); - if ((await treeItem.count()) > 0) { - await treeItem.click({ force: true }); - } - } - - await page.waitForTimeout(1000); - - // Wait for content area to be ready - await page - .waitForFunction( - () => { - const nodeContent = document.querySelector('#node-content'); - const metadata = document.querySelector('#properties-node-content-form'); - return nodeContent && (!metadata || !metadata.closest('.show')); - }, - { timeout: 10000 }, - ) - .catch(() => {}); - - // Add text iDevice via quick button or panel - const quickTextButton = page - .locator('[data-testid="quick-idevice-text"], .quick-idevice-btn[data-idevice="text"]') - .first(); - if ((await quickTextButton.count()) > 0 && (await quickTextButton.isVisible())) { - await quickTextButton.click(); - } else { - const infoCategory = page - .locator('#menu_idevices .accordion-item') - .filter({ hasText: /Information|Información/i }) - .locator('.accordion-button'); - - if ((await infoCategory.count()) > 0) { - const isCollapsed = await infoCategory.first().evaluate(el => el.classList.contains('collapsed')); - if (isCollapsed) { - await infoCategory.first().click(); - await page.waitForTimeout(500); - } - } - - const textIdevice = page.locator('.idevice_item[id="text"], [data-testid="idevice-text"]').first(); - await textIdevice.waitFor({ state: 'visible', timeout: 10000 }); - await textIdevice.click(); - } - - // Wait for iDevice to appear - await page.locator('#node-content article .idevice_node.text').first().waitFor({ timeout: 15000 }); -} - /** * Helper to type content in TinyMCE editor */ @@ -236,7 +161,7 @@ test.describe('Collaborative Text iDevice', () => { await waitForYjsSync(pageB); // Client A adds a text iDevice - await addTextIdeviceFromPanel(pageA); + await addTextIdevice(pageA); // Wait for TinyMCE to be ready await pageA.waitForSelector('.tox-menubar', { timeout: 15000 }); @@ -306,17 +231,14 @@ test.describe('Collaborative Text iDevice', () => { await waitForLoadingScreenHidden(pageA); // Client A adds a text iDevice and types content - await addTextIdeviceFromPanel(pageA); + await addTextIdevice(pageA); await pageA.waitForSelector('.tox-menubar', { timeout: 15000 }); const uniqueText = `Text-only collaborative content ${Date.now()}`; await typeInTinyMCE(pageA, uniqueText); await saveTextIdevice(pageA); - // Wait for content to be saved to Yjs - await pageA.waitForTimeout(2000); - - // Verify content is visible on Client A + // Wait for content to be visible on Client A (verifies save completed) await expect(pageA.locator('#node-content')).toContainText(uniqueText, { timeout: 10000 }); // Client A makes project public and shares @@ -326,17 +248,12 @@ test.describe('Collaborative Text iDevice', () => { await joinSharedProject(pageB, shareUrl); await waitForYjsSync(pageB); - // Wait for Yjs document to sync - await pageB.waitForTimeout(3000); - - // Client B navigates to the page with the iDevice - const pageNode = pageB - .locator('.nav-element-text') - .filter({ hasText: /New page|Nueva página/i }) - .first(); - if ((await pageNode.count()) > 0) { - await pageNode.click({ force: true }); - await pageB.waitForTimeout(1500); + // Client B navigates to the page with the iDevice using centralized helper + try { + await navigateToPageByTitle(pageB, 'New page'); + } catch { + // Try Spanish version + await navigateToPageByTitle(pageB, 'Nueva página'); } // Verify Client B sees the text content diff --git a/test/e2e/playwright/specs/idevices-draganddrop.spec.ts b/test/e2e/playwright/specs/idevices-draganddrop.spec.ts index f89e177c5..fa36f77be 100644 --- a/test/e2e/playwright/specs/idevices-draganddrop.spec.ts +++ b/test/e2e/playwright/specs/idevices-draganddrop.spec.ts @@ -232,7 +232,10 @@ async function handleConfirmDialog(page: Page, confirm: boolean): Promise if ((await btn.count()) > 0) { await btn.first().click(); - await page.waitForTimeout(500); + // Wait for modal to be dismissed instead of fixed timeout + await page.waitForFunction(() => document.querySelectorAll('.modal.show').length === 0, null, { + timeout: 5000, + }); } } } @@ -615,15 +618,22 @@ test.describe('iDevice Drag and Drop', () => { await dragAndDrop(page, dragHandle, block1); await handleConfirmDialog(page, true); // Confirm delete of empty Block 2 + // Wait for drag-and-drop to complete: either block count reduces OR iDevice moved to target await page.waitForFunction( () => { const blocks = document.querySelectorAll('#node-content article.box'); + // Success condition 1: Block count reduced (empty block was deleted) if (blocks.length === 2) return true; - const block1 = blocks[0]; - return !!block1 && block1.querySelectorAll('.idevice_node').length === 2; + // Success condition 2: iDevice successfully moved to first block (even if deletion is pending) + if (blocks.length >= 2) { + const firstBlock = blocks[0]; + const ideviceCount = firstBlock?.querySelectorAll('.idevice_node').length; + if (ideviceCount === 2) return true; + } + return false; }, null, - { timeout: 10000 }, + { timeout: 15000 }, ); // Now we should have Block 1 (with 2 iDevices) and Block 3 (with 1 iDevice) diff --git a/test/e2e/playwright/specs/idevices/interactive-video.spec.ts b/test/e2e/playwright/specs/idevices/interactive-video.spec.ts index 9dde0b290..a0e8833c3 100644 --- a/test/e2e/playwright/specs/idevices/interactive-video.spec.ts +++ b/test/e2e/playwright/specs/idevices/interactive-video.spec.ts @@ -710,14 +710,11 @@ test.describe('Interactive Video iDevice', () => { }); }); - test.describe('Symfony Compatibility Shim', () => { - test('should have eXeLearning.symfony defined after page load', async ({ - authenticatedPage, - createProject, - }) => { + test.describe('Configuration API', () => { + test('should have eXeLearning.config defined after page load', async ({ authenticatedPage, createProject }) => { const page = authenticatedPage; - const projectUuid = await createProject(page, 'Symfony Shim Test'); + const projectUuid = await createProject(page, 'Config API Test'); await page.goto(`/workarea?project=${projectUuid}`); await page.waitForLoadState('networkidle'); @@ -731,21 +728,27 @@ test.describe('Interactive Video iDevice', () => { await waitForLoadingScreenHidden(page); - // Verify eXeLearning.symfony exists and has expected properties - const symfonyShim = await page.evaluate(() => { - const symfony = (window as any).eXeLearning?.symfony; + // Verify eXeLearning.config exists and has expected properties + const configAPI = await page.evaluate(() => { + const config = (window as any).eXeLearning?.config; return { - exists: symfony !== undefined, - hasBaseURL: symfony?.baseURL !== undefined, - hasBasePath: symfony?.basePath !== undefined, - hasFullURL: symfony?.fullURL !== undefined, + exists: config !== undefined, + hasBaseURL: config?.baseURL !== undefined, + hasBasePath: config?.basePath !== undefined, + hasFullURL: config?.fullURL !== undefined, }; }); - expect(symfonyShim.exists).toBe(true); - expect(symfonyShim.hasBaseURL).toBe(true); - expect(symfonyShim.hasBasePath).toBe(true); - expect(symfonyShim.hasFullURL).toBe(true); + expect(configAPI.exists).toBe(true); + expect(configAPI.hasBaseURL).toBe(true); + expect(configAPI.hasBasePath).toBe(true); + expect(configAPI.hasFullURL).toBe(true); + + // Verify resolveAssetUrl function is available (via eXeLearningAssetResolver) + const hasResolveAssetUrl = await page.evaluate(() => { + return typeof (window as any).eXeLearningAssetResolver?.resolve === 'function'; + }); + expect(hasResolveAssetUrl).toBe(true); }); }); }); diff --git a/test/e2e/playwright/specs/idevices/text.spec.ts b/test/e2e/playwright/specs/idevices/text.spec.ts index 8737b29d6..b83ee675f 100644 --- a/test/e2e/playwright/specs/idevices/text.spec.ts +++ b/test/e2e/playwright/specs/idevices/text.spec.ts @@ -1,6 +1,6 @@ import { test, expect, waitForLoadingScreenHidden } from '../../fixtures/auth.fixture'; import { WorkareaPage } from '../../pages/workarea.page'; -import type { Page } from '@playwright/test'; +import { addTextIdevice } from '../../helpers/workarea-helpers'; /** * E2E Tests for Text iDevice @@ -12,93 +12,6 @@ import type { Page } from '@playwright/test'; * - Text formatting and persistence */ -/** - * Helper to add a text iDevice by selecting the page and clicking the text iDevice - */ -async function addTextIdeviceFromPanel(page: Page): Promise { - // First, select a page in the navigation tree (click on "New page" text) - // The page node might be a span or button inside the tree structure - const pageNodeSelectors = [ - '.nav-element-text:has-text("New page")', - '.nav-element-text:has-text("Nueva página")', - '[data-testid="nav-node-text"]', - '.structure-tree li .nav-element-text', - ]; - - let pageSelected = false; - for (const selector of pageNodeSelectors) { - const element = page.locator(selector).first(); - if ((await element.count()) > 0) { - try { - // Force click since element might be partially hidden - await element.click({ force: true, timeout: 5000 }); - pageSelected = true; - break; - } catch { - // Try next selector - } - } - } - - if (!pageSelected) { - // Try clicking on the page icon or the whole tree item - const treeItem = page.locator('#menu_structure .structure-tree li').first(); - if ((await treeItem.count()) > 0) { - await treeItem.click({ force: true }); - } - } - - // Wait for the page content area to switch from metadata to page editor - await page.waitForTimeout(1000); - - // Wait for node-content to show page content (not project metadata) - await page - .waitForFunction( - () => { - const nodeContent = document.querySelector('#node-content'); - const metadata = document.querySelector('#properties-node-content-form'); - // Either metadata is hidden or node-content shows page content - return nodeContent && (!metadata || !metadata.closest('.show')); - }, - { timeout: 10000 }, - ) - .catch(() => { - // Continue anyway - }); - - // Try to use quick access button first (at bottom of page content area) - const quickTextButton = page - .locator('[data-testid="quick-idevice-text"], .quick-idevice-btn[data-idevice="text"]') - .first(); - if ((await quickTextButton.count()) > 0 && (await quickTextButton.isVisible())) { - await quickTextButton.click(); - } else { - // Expand "Information and presentation" category in iDevices panel - const infoCategory = page - .locator('#menu_idevices .accordion-item') - .filter({ - hasText: /Information|Información/i, - }) - .locator('.accordion-button'); - - if ((await infoCategory.count()) > 0) { - const isCollapsed = await infoCategory.first().evaluate(el => el.classList.contains('collapsed')); - if (isCollapsed) { - await infoCategory.first().click(); - await page.waitForTimeout(500); - } - } - - // Find and click the text iDevice - const textIdevice = page.locator('.idevice_item[id="text"], [data-testid="idevice-text"]').first(); - await textIdevice.waitFor({ state: 'visible', timeout: 10000 }); - await textIdevice.click(); - } - - // Wait for iDevice to appear in content area - await page.locator('#node-content article .idevice_node.text').first().waitFor({ timeout: 15000 }); -} - test.describe('Text iDevice', () => { test.describe('Basic Operations', () => { test('should add text iDevice and edit content', async ({ authenticatedPage, createProject }) => { @@ -122,7 +35,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add a text iDevice using the panel - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); // Verify iDevice was added const textIdevice = page.locator('#node-content article .idevice_node.text').first(); @@ -162,7 +75,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add and edit text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); const uniqueContent = `Unique content for persistence test ${Date.now()}`; await workarea.editFirstTextIdevice(uniqueContent); @@ -221,7 +134,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add a text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); // Check if already in edit mode (TinyMCE visible) or need to click edit button const tinyMceMenubar = page.locator('.tox-menubar'); @@ -301,7 +214,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add a text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); const block = page.locator('#node-content article .idevice_node.text').last(); await block.waitFor({ timeout: 10000 }); @@ -340,8 +253,8 @@ test.describe('Text iDevice', () => { const dialog = page.locator('.tox-dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); - // Get the codemagic frame (now served via API endpoint) - const codemagicFrame = page.frameLocator('iframe[src*="codemagic-editor"]'); + // Get the codemagic frame (served via resolveAssetUrl) + const codemagicFrame = page.frameLocator('iframe[src*="codemagic.html"]'); // Wait for CodeMirror to be initialized await codemagicFrame.locator('.CodeMirror').waitFor({ timeout: 10000 }); @@ -351,7 +264,7 @@ test.describe('Text iDevice', () => { const testHtml = `

HTML edited via CodeMagic

`; // Get the iframe element and use evaluate to set CodeMirror content - const iframeHandle = await page.locator('iframe[src*="codemagic-editor"]').elementHandle(); + const iframeHandle = await page.locator('iframe[src*="codemagic.html"]').elementHandle(); const frame = await iframeHandle?.contentFrame(); if (frame) { // Wait for CodeMirror element to be available (it stores a reference on the DOM element) @@ -425,7 +338,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add a text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); // Check if already in edit mode (TinyMCE visible) or need to click edit button const tinyMceMenubar = page.locator('.tox-menubar'); @@ -538,7 +451,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add a text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); const block = page.locator('#node-content article .idevice_node.text').last(); await block.waitFor({ timeout: 10000 }); @@ -593,7 +506,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add a text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); const block = page.locator('#node-content article .idevice_node.text').last(); await block.waitFor({ timeout: 10000 }); @@ -792,7 +705,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add a text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); const block = page.locator('#node-content article .idevice_node.text').last(); await block.waitFor({ timeout: 10000 }); @@ -930,7 +843,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add a text iDevice with mermaid diagram - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); const block = page.locator('#node-content article .idevice_node.text').last(); await block.waitFor({ timeout: 10000 }); @@ -1085,7 +998,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add a text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); // Check if already in edit mode (TinyMCE visible) or need to click edit button const tinyMceMenubar = page.locator('.tox-menubar'); @@ -1210,7 +1123,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add a text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); const block = page.locator('#node-content article .idevice_node.text').last(); await block.waitFor({ timeout: 10000 }); @@ -1356,7 +1269,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add a text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); const block = page.locator('#node-content article .idevice_node.text').last(); await block.waitFor({ timeout: 10000 }); @@ -1430,7 +1343,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add a text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); const block = page.locator('#node-content article .idevice_node.text').last(); await block.waitFor({ timeout: 10000 }); @@ -1528,7 +1441,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add a text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); const block = page.locator('#node-content article .idevice_node.text').last(); await block.waitFor({ timeout: 10000 }); @@ -1709,7 +1622,7 @@ test.describe('Text iDevice', () => { ); // 3. Add text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); // 4. Enter edit mode const block = page.locator('#node-content article .idevice_node.text').first(); @@ -1869,7 +1782,7 @@ test.describe('Text iDevice', () => { ); // 3. Add text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); // 4. Enter edit mode const block = page.locator('#node-content article .idevice_node.text').first(); @@ -2044,7 +1957,7 @@ test.describe('Text iDevice', () => { } // Add a text iDevice on the first page - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); const block = page.locator('#node-content article .idevice_node.text').last(); await block.waitFor({ timeout: 10000 }); @@ -2254,7 +2167,7 @@ test.describe('Text iDevice', () => { } // Add text iDevice with internal link - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); const block = page.locator('#node-content article .idevice_node.text').last(); await block.waitFor({ timeout: 10000 }); @@ -2421,7 +2334,7 @@ test.describe('Text iDevice', () => { await waitForLoadingScreenHidden(page); // Add a text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); // Get TinyMCE editor and add content with exe-package:elp link await page.waitForSelector('.tox-editor-header', { timeout: 15000 }); @@ -2578,7 +2491,7 @@ test.describe('Text iDevice', () => { ); // 2. Add a text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); // Wait for iDevice to be visible const block = page.locator('#node-content article .idevice_node.text').last(); @@ -2720,7 +2633,7 @@ test.describe('Text iDevice', () => { ); // 2. Add a text iDevice - await addTextIdeviceFromPanel(page); + await addTextIdevice(page); // Wait for iDevice to be visible const block = page.locator('#node-content article .idevice_node.text').last(); diff --git a/test/e2e/playwright/specs/search-preview-navigation.spec.ts b/test/e2e/playwright/specs/search-preview-navigation.spec.ts index 1d667cee8..38d410f30 100644 --- a/test/e2e/playwright/specs/search-preview-navigation.spec.ts +++ b/test/e2e/playwright/specs/search-preview-navigation.spec.ts @@ -1,4 +1,5 @@ import { test, expect, Page, waitForLoadingScreenHidden } from '../fixtures/auth.fixture'; +import { selectPageByIndex } from '../helpers/workarea-helpers'; /** * E2E Tests for Search Navigation in Preview @@ -11,19 +12,15 @@ import { test, expect, Page, waitForLoadingScreenHidden } from '../fixtures/auth /** * Helper to select a non-root page node in the navigation tree + * Uses the centralized helper which properly waits for content area */ async function selectPageNode(page: Page, index = 0): Promise { - const pageNodes = page.locator('.nav-element:not([nav-id="root"]) .nav-element-text'); - const count = await pageNodes.count(); - - if (count > index) { - const element = pageNodes.nth(index); - await element.waitFor({ state: 'visible', timeout: 5000 }); - await element.click({ timeout: 5000 }); - await page.waitForTimeout(500); + try { + await selectPageByIndex(page, index); return true; + } catch { + return false; } - return false; } /** diff --git a/test/integration/html5-export-fixture.spec.ts b/test/integration/html5-export-fixture.spec.ts index 95360c0ba..26461c789 100644 --- a/test/integration/html5-export-fixture.spec.ts +++ b/test/integration/html5-export-fixture.spec.ts @@ -295,12 +295,20 @@ describe('HTML5 Export Fixture Comparison', () => { expect(exportedIndexHtml).toContain('id="siteFooterContent"'); }); - it('should have packageLicense inside footer', async () => { - if (!exportedIndexHtml) return; - - expect(exportedIndexHtml).toContain('id="packageLicense"'); - expect(exportedIndexHtml).toContain('class="license-label"'); - expect(exportedIndexHtml).toContain('class="license"'); + it('should have packageLicense inside footer when license is set', async () => { + if (!exportedIndexHtml || !parsedStructure) return; + + // packageLicense is only rendered when license is set in metadata + const hasLicense = !!parsedStructure.meta?.license; + if (hasLicense) { + expect(exportedIndexHtml).toContain('id="packageLicense"'); + expect(exportedIndexHtml).toContain('class="license-label"'); + expect(exportedIndexHtml).toContain('class="license"'); + } else { + // When no license, footer exists but without packageLicense + expect(exportedIndexHtml).toContain('id="siteFooter"'); + expect(exportedIndexHtml).not.toContain('id="packageLicense"'); + } }); it('should have made-with-eXe credit', async () => {