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