Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
bf8c793
Fix #1023 - Integrate v3.0.2 TinyMCE fixes and remove the image optim…
ignaciogros Jan 17, 2026
d96ded6
The default alt text for an image selected from the File Manager shou…
ignaciogros Jan 17, 2026
0b6dbc9
FIGURE elements, like IMG elements, should have a max-width of 100% r…
ignaciogros Jan 17, 2026
7955808
Preserve existing classes and captions (custom license).
ignaciogros Jan 17, 2026
02d3ef3
Update the test to check if the accessibility warning is displayed wh…
ignaciogros Jan 17, 2026
49adead
Update test to handle the accessibility warning dialog.
ignaciogros Jan 17, 2026
b115f9a
Fix "TinyMCE Advanced Editor (CodeMagic)" test.
ignaciogros Jan 17, 2026
2c5175d
Merge branch 'main' into 1023-tinymce-plugins-integrate-v302-fixes
ignaciogros Jan 17, 2026
d4d43ed
Fix PDF preview test to work with either <iframe> or <embed>.
ignaciogros Jan 17, 2026
9f40727
Merge branch 'main' into 1023-tinymce-plugins-integrate-v302-fixes
erseco Jan 17, 2026
14988c7
Fill in alt text in the "exeimage" plugin to avoid accessibility warn…
ignaciogros Jan 17, 2026
5605f5a
Remove the 'Image Optimizer' test (that optimizer is no longer in eXe).
ignaciogros Jan 17, 2026
4c8e487
Merge branch 'main' of https://github.com/exelearning/exelearning int…
ignaciogros Jan 17, 2026
b175009
Merge branch '1023-tinymce-plugins-integrate-v302-fixes' of https://g…
ignaciogros Jan 17, 2026
1b0c337
Fixed tests: 'should persist image after save and reload' and 'should…
ignaciogros Jan 17, 2026
4b52de3
Fix lint issue.
ignaciogros Jan 18, 2026
ff9fdc3
Fix 'Undo/Redo iDevice Icon' test.
ignaciogros Jan 18, 2026
dc9c1c4
Merge branch 'main' of github.com:exelearning/exelearning
erseco Jan 18, 2026
45e8af2
Merge branch 'main' of github.com:exelearning/exelearning
erseco Jan 18, 2026
ab2b8b8
Merge branch 'main' of github.com:exelearning/exelearning
erseco Jan 18, 2026
21f6981
Merge branch 'main' of github.com:exelearning/exelearning
erseco Jan 18, 2026
85aafee
Merge branch 'main' of github.com:exelearning/exelearning
erseco Jan 19, 2026
ff9b831
Merge branch 'main' of github.com:exelearning/exelearning
erseco Jan 20, 2026
e7e5257
Merge branch 'main' of github.com:exelearning/exelearning
erseco Jan 20, 2026
5a9c6aa
Merge branch 'main' of github.com:exelearning/exelearning
erseco Jan 20, 2026
d919997
Merge branch 'main' of github.com:exelearning/exelearning
erseco Jan 21, 2026
d086990
Merge branch 'main' of github.com:exelearning/exelearning
erseco Jan 21, 2026
e522ae3
Align license handling with eXe 3 and restore missing legacy licenses…
erseco Jan 21, 2026
52efb67
Merge branch 'main' of github.com:exelearning/exelearning
erseco Jan 21, 2026
b5cc406
Improved e2e tests for reliability
erseco Jan 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions assets/styles/pages/_properties.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
11 changes: 10 additions & 1 deletion public/app/workarea/modals/modals/pages/modalFileManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
});
}


Expand Down
8 changes: 6 additions & 2 deletions public/app/workarea/project/properties/formProperties.js
Original file line number Diff line number Diff line change
Expand Up @@ -405,19 +405,23 @@ 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 || {}
)) {
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;
Expand Down
4 changes: 4 additions & 0 deletions public/app/workarea/project/properties/formProperties.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ describe('FormProperties', () => {

expect(row.getAttribute('duplicate')).toBe('3');
});

});

describe('makeRowElementLabel', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
10 changes: 9 additions & 1 deletion public/app/yjs/ElpxImporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
90 changes: 90 additions & 0 deletions public/app/yjs/ElpxImporter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<?xml version="1.0"?>
<ode>
<odeProperties>
<odeProperty><key>pp_title</key><value>Test Project V3</value></odeProperty>
<odeProperty><key>license</key><value>creative commons: attribution - share alike 4.0</value></odeProperty>
</odeProperties>
<odeNavStructures>
<odeNavStructure>
<odePageId>page1</odePageId>
<pageName>Page 1</pageName>
<odeNavStructureOrder>1</odeNavStructureOrder>
<odePagStructures></odePagStructures>
</odeNavStructure>
</odeNavStructures>
</ode>`;

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 = `<?xml version="1.0"?>
<ode>
<odeProperties>
<odeProperty><key>pp_title</key><value>Test Project</value></odeProperty>
<odeProperty><key>pp_license</key><value>public domain</value></odeProperty>
<odeProperty><key>license</key><value>creative commons: attribution 4.0</value></odeProperty>
</odeProperties>
<odeNavStructures>
<odeNavStructure>
<odePageId>page1</odePageId>
<pageName>Page 1</pageName>
<odeNavStructureOrder>1</odeNavStructureOrder>
<odePagStructures></odePagStructures>
</odeNavStructure>
</odeNavStructures>
</ode>`;

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 = `<?xml version="1.0"?>
<ode>
<odeProperties>
<odeProperty><key>pp_title</key><value>Test Project</value></odeProperty>
<odeProperty><key>license</key><value></value></odeProperty>
</odeProperties>
<odeNavStructures>
<odeNavStructure>
<odePageId>page1</odePageId>
<pageName>Page 1</pageName>
<odeNavStructureOrder>1</odeNavStructureOrder>
<odePagStructures></odePagStructures>
</odeNavStructure>
</odeNavStructures>
</ode>`;

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', () => {
Expand Down
11 changes: 10 additions & 1 deletion public/app/yjs/LegacyXmlParser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
81 changes: 81 additions & 0 deletions public/app/yjs/LegacyXmlParser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,86 @@ describe('LegacyXmlParser', () => {
expect(meta.pp_addAccessibilityToolbar).toBe(false);
});

it('extracts license from package', () => {
const xml = `<?xml version="1.0"?>
<root>
<instance class="exe.engine.package.Package">
<dictionary>
<string role="key" value="_title"/>
<unicode value="Project With License"/>
<string role="key" value="license"/>
<unicode value="creative commons: attribution - share alike 4.0"/>
</dictionary>
</instance>
</root>`;

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 = `<?xml version="1.0"?>
<root>
<instance class="exe.engine.package.Package">
<dictionary>
<string role="key" value="_title"/>
<unicode value="Project With None License"/>
<string role="key" value="license"/>
<unicode value="None"/>
</dictionary>
</instance>
</root>`;

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 = `<?xml version="1.0"?>
<root>
<instance class="exe.engine.package.Package">
<dictionary>
<string role="key" value="_title"/>
<unicode value="No License Project"/>
</dictionary>
</instance>
</root>`;

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 = `<?xml version="1.0"?>
<root>
<instance class="exe.engine.package.Package">
<dictionary>
<string role="key" value="_title"/>
<unicode value="Empty License Project"/>
<string role="key" value="license"/>
<unicode value=""/>
</dictionary>
</instance>
</root>`;

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 = `<?xml version="1.0"?>
<root>
Expand All @@ -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');
Expand Down
58 changes: 58 additions & 0 deletions public/app/yjs/YjsPropertiesBinding.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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':
Expand All @@ -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
*/
Expand Down
Loading
Loading