diff --git a/package-lock.json b/package-lock.json
index bac51c85c5..e7a41a4271 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -50,6 +50,7 @@
"file-saver": "^2.0.5",
"formik": "2.4.6",
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
+ "iso-639-1": "^3.1.5",
"jszip": "^3.10.1",
"lodash": "4.17.21",
"meilisearch": "^0.41.0",
@@ -14149,6 +14150,15 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
+ "node_modules/iso-639-1": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-3.1.5.tgz",
+ "integrity": "sha512-gXkz5+KN7HrG0Q5UGqSMO2qB9AsbEeyLP54kF1YrMsIxmu+g4BdB7rflReZTSTZGpfj8wywu6pfPBCylPIzGQA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0"
+ }
+ },
"node_modules/isobject": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
diff --git a/package.json b/package.json
index db3a39dde7..31bb3f8aa1 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,7 @@
"start": "fedx-scripts webpack-dev-server --progress",
"start:with-theme": "paragon install-theme && npm start && npm install",
"dev": "PUBLIC_PATH=/authoring/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
- "test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
+ "test": "TZ=UTC fedx-scripts jest --passWithNoTests",
"test:ci": "TZ=UTC fedx-scripts jest --silent --coverage --passWithNoTests",
"types": "tsc --noEmit"
},
@@ -73,6 +73,7 @@
"file-saver": "^2.0.5",
"formik": "2.4.6",
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
+ "iso-639-1": "^3.1.5",
"jszip": "^3.10.1",
"lodash": "4.17.21",
"meilisearch": "^0.41.0",
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx
index ad441c562d..5164a6fdfb 100644
--- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx
@@ -12,8 +12,8 @@ import { Check } from '@openedx/paragon/icons';
import { connect, useDispatch } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { thunkActions, selectors } from '../../../../../../data/redux';
-import { videoTranscriptLanguages } from '../../../../../../data/constants/video';
import { FileInput, fileInput } from '../../../../../../sharedComponents/FileInput';
+import { getLanguageName } from '../../../../../../data/constants/video';
import messages from './messages';
export const hooks = {
@@ -45,27 +45,27 @@ export const hooks = {
};
const LanguageSelector = ({
- index, // For a unique id for the form control
+ index,
language,
- // Redux
- openLanguages, // Only allow those languages not already associated with a transcript to be selected
+ openLanguages,
}) => {
const intl = useIntl();
+ const dispatch = useDispatch();
+
const [localLang, setLocalLang] = React.useState(language);
- const input = fileInput({ onAddFile: hooks.addFileCallback({ dispatch: useDispatch(), localLang }) });
+ const input = fileInput({ onAddFile: hooks.addFileCallback({ dispatch, localLang }) });
const onLanguageChange = hooks.onSelectLanguage({
- dispatch: useDispatch(), languageBeforeChange: localLang, setLocalLang, triggerupload: input.click,
+ dispatch, languageBeforeChange: localLang, setLocalLang, triggerupload: input.click,
});
const getTitle = () => {
- if (Object.prototype.hasOwnProperty.call(videoTranscriptLanguages, language)) {
+ if (language) {
return (
- {videoTranscriptLanguages[language]}
+ {getLanguageName(language)}
-
);
}
return (
@@ -78,10 +78,7 @@ const LanguageSelector = ({
return (
<>
-
-
+
- {Object.entries(videoTranscriptLanguages).map(([lang, text]) => {
+ {openLanguages.map(lang => {
+ const name = getLanguageName(lang);
+
if (language === lang) {
- return ({text});
- }
- if (openLanguages.some(row => row.includes(lang))) {
- return ( onLanguageChange({ newLang: lang })}>{text});
+ return (
+
+ {name}
+
+
+ );
}
- return ({text});
+ return (
+ onLanguageChange({ newLang: lang })}
+ >
+ {name}
+
+ );
})}
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.test.jsx
index 3fc95017b7..0adef88aac 100644
--- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.test.jsx
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.test.jsx
@@ -1,6 +1,6 @@
import React from 'react';
import {
- render, screen, initializeMocks, fireEvent,
+ render, screen, initializeMocks,
} from '@src/testUtils';
import LanguageSelector from './LanguageSelector';
import { selectors } from '../../../../../../data/redux';
@@ -13,11 +13,14 @@ const lang3 = 'sImLisH';
const lang3Code = 'sl';
jest.mock('../../../../../../data/constants/video', () => ({
- videoTranscriptLanguages: {
- [lang1Code]: lang1,
- [lang2Code]: lang2,
- [lang3Code]: lang3,
- },
+ getLanguageName: jest.fn((code) => {
+ const mockMap = {
+ kl: lang1,
+ el: lang2,
+ sl: lang3,
+ };
+ return mockMap[code] || code;
+ }),
}));
describe('LanguageSelector', () => {
@@ -25,7 +28,7 @@ describe('LanguageSelector', () => {
onSelect: jest.fn().mockName('props.OnSelect'),
index: 1,
language: lang1Code,
- openLanguages: [[lang2Code, lang2], [lang3Code, lang3]],
+ openLanguages: [lang2Code, lang3Code, lang1Code],
};
beforeEach(() => {
initializeMocks();
@@ -46,13 +49,4 @@ describe('LanguageSelector', () => {
render();
expect(screen.getByText('Select Language')).toBeInTheDocument();
});
-
- test('transcripts no Open Languages, all dropdown items should be disabled', () => {
- const { video } = selectors;
- jest.spyOn(video, 'openLanguages').mockReturnValue([]);
- const { container } = render();
- fireEvent.click(screen.getByRole('button', { name: 'Languages' }));
- const disabledItems = container.querySelectorAll('.disabled.dropdown-item');
- expect(disabledItems.length).toBe(3);
- });
});
diff --git a/src/editors/data/constants/video.js b/src/editors/data/constants/video.js
index f5a24ed1ca..90b843ab2c 100644
--- a/src/editors/data/constants/video.js
+++ b/src/editors/data/constants/video.js
@@ -1,209 +1,49 @@
+import ISO6391 from 'iso-639-1';
import { StrictDict } from '../../utils';
-export const videoTranscriptLanguages = StrictDict({
- placeholder: '',
- aa: 'Afar',
- ab: 'Abkhazian',
- af: 'Afrikaans',
- ak: 'Akan',
- sq: 'Albanian',
- am: 'Amharic',
- ar: 'Arabic',
- an: 'Aragonese',
- hy: 'Armenian',
- as: 'Assamese',
- av: 'Avaric',
- ae: 'Avestan',
- ay: 'Aymara',
- az: 'Azerbaijani',
- ba: 'Bashkir',
- bm: 'Bambara',
- eu: 'Basque',
- be: 'Belarusian',
- bn: 'Bengali',
- bh: 'Bihari languages',
- bi: 'Bislama',
- bs: 'Bosnian',
- br: 'Breton',
- bg: 'Bulgarian',
- my: 'Burmese',
- ca: 'Catalan',
- ch: 'Chamorro',
- ce: 'Chechen',
- zh: 'Chinese',
- zh_HANS: 'Simplified Chinese',
- zh_HANT: 'Traditional Chinese',
- cu: 'Church Slavic',
- cv: 'Chuvash',
- kw: 'Cornish',
- co: 'Corsican',
- cr: 'Cree',
- cs: 'Czech',
- da: 'Danish',
- dv: 'Divehi',
- nl: 'Dutch',
- dz: 'Dzongkha',
- en: 'English',
- eo: 'Esperanto',
- et: 'Estonian',
- ee: 'Ewe',
- fo: 'Faroese',
- fj: 'Fijian',
- fi: 'Finnish',
- fr: 'French',
- fy: 'Western Frisian',
- ff: 'Fulah',
- ka: 'Georgian',
- de: 'German',
- gd: 'Gaelic',
- ga: 'Irish',
- gl: 'Galician',
- gv: 'Manx',
- el: 'Greek',
- gn: 'Guarani',
- gu: 'Gujarati',
- ht: 'Haitian',
- ha: 'Hausa',
- he: 'Hebrew',
- hz: 'Herero',
- hi: 'Hindi',
- ho: 'Hiri Motu',
- hr: 'Croatian',
- hu: 'Hungarian',
- ig: 'Igbo',
- is: 'Icelandic',
- io: 'Ido',
- ii: 'Sichuan Yi',
- iu: 'Inuktitut',
- ie: 'Interlingue',
- ia: 'Interlingua',
- id: 'Indonesian',
- ik: 'Inupiaq',
- it: 'Italian',
- jv: 'Javanese',
- ja: 'Japanese',
- kl: 'Kalaallisut',
- kn: 'Kannada',
- ks: 'Kashmiri',
- kr: 'Kanuri',
- kk: 'Kazakh',
- km: 'Central Khmer',
- ki: 'Kikuyu',
- rw: 'Kinyarwanda',
- ky: 'Kirghiz',
- kv: 'Komi',
- kg: 'Kongo',
- ko: 'Korean',
- kj: 'Kuanyama',
- ku: 'Kurdish',
- lo: 'Lao',
- la: 'Latin',
- lv: 'Latvian',
- li: 'Limburgan',
- ln: 'Lingala',
- lt: 'Lithuanian',
- lb: 'Luxembourgish',
- lu: 'Luba-Katanga',
- lg: 'Ganda',
- mk: 'Macedonian',
- mh: 'Marshallese',
- ml: 'Malayalam',
- mi: 'Maori',
- mr: 'Marathi',
- ms: 'Malay',
- mg: 'Malagasy',
- mt: 'Maltese',
- mn: 'Mongolian',
- na: 'Nauru',
- nv: 'Navajo',
- nr: 'Ndebele: South',
- nd: 'Ndebele: North',
- ng: 'Ndonga',
- ne: 'Nepali',
- nn: 'Norwegian Nynorsk',
- nb: 'Bokmål: Norwegian',
- no: 'Norwegian',
- ny: 'Chichewa',
- oc: 'Occitan',
- oj: 'Ojibwa',
- or: 'Oriya',
- om: 'Oromo',
- os: 'Ossetian',
- pa: 'Panjabi',
- fa: 'Persian',
- pi: 'Pali',
- pl: 'Polish',
- pt: 'Portuguese',
- 'pt-BR': 'Portuguese (Brazil)',
- ps: 'Pushto',
- qu: 'Quechua',
- rm: 'Romansh',
- ro: 'Romanian',
- rn: 'Rundi',
- ru: 'Russian',
- sg: 'Sango',
- sa: 'Sanskrit',
- si: 'Sinhala',
- sk: 'Slovak',
- sl: 'Slovenian',
- se: 'Northern Sami',
- sm: 'Samoan',
- sn: 'Shona',
- sd: 'Sindhi',
- so: 'Somali',
- st: 'Sotho: Southern',
- es: 'Spanish',
- sc: 'Sardinian',
- sr: 'Serbian',
- ss: 'Swati',
- su: 'Sundanese',
- sw: 'Swahili',
- sv: 'Swedish',
- ty: 'Tahitian',
- ta: 'Tamil',
- tt: 'Tatar',
- te: 'Telugu',
- tg: 'Tajik',
- tl: 'Tagalog',
- th: 'Thai',
- bo: 'Tibetan',
- ti: 'Tigrinya',
- to: 'Tonga (Tonga Islands)',
- tn: 'Tswana',
- ts: 'Tsonga',
- tk: 'Turkmen',
- tr: 'Turkish',
- tw: 'Twi',
- ug: 'Uighur',
- uk: 'Ukrainian',
- ur: 'Urdu',
- uz: 'Uzbek',
- ve: 'Venda',
- vi: 'Vietnamese',
- vo: 'Volapük',
- cy: 'Welsh',
- wa: 'Walloon',
- wo: 'Wolof',
- xh: 'Xhosa',
- yi: 'Yiddish',
- yo: 'Yoruba',
- za: 'Zhuang',
- zu: 'Zulu',
-});
+export const getLanguageName = (langCode, locales = ['en']) => {
+ const code = langCode?.toLowerCase();
+ if (!code) { return ''; }
+
+ for (const locale of locales) {
+ try {
+ const dn = new Intl.DisplayNames([locale], { type: 'language' });
+ const name = dn.of(code);
+ if (name && name !== code) {
+ return name;
+ }
+ } catch {
+ // Fallback to ISO6391 if Intl.DisplayNames fails
+ }
+ }
+ const isoName = ISO6391.getName(code);
+ if (isoName) { return isoName; }
+
+ return code;
+};
+
+// Zero-maintenance: generate an object mapping code → name from iso-639-1 package
+export const openLanguagesDataSet = ISO6391.getAllCodes().reduce((acc, code) => {
+ acc[code] = ISO6391.getName(code);
+ return acc;
+}, {});
export const in8lTranscriptLanguages = (intl) => {
const messageLookup = {};
- // for tests and non-internationlized setups, return en
+
+ // For tests and non-internationalized setups, return raw dataset
if (!intl?.formatMessage) {
- return videoTranscriptLanguages;
+ return openLanguagesDataSet;
}
- Object.keys(videoTranscriptLanguages).forEach((code) => {
+
+ Object.keys(openLanguagesDataSet).forEach((code) => {
messageLookup[code] = intl.formatMessage({
id: `authoring.videoeditor.transcripts.language.${code}`,
- defaultMessage: videoTranscriptLanguages[code],
- description: `Name of Language called in English ${videoTranscriptLanguages[code]}`,
+ defaultMessage: openLanguagesDataSet[code],
+ description: `Name of Language called in English ${openLanguagesDataSet[code]}`,
});
});
+
return messageLookup;
};
@@ -214,5 +54,4 @@ export const timeKeys = StrictDict({
export default {
timeKeys,
- videoTranscriptLanguages,
};
diff --git a/src/editors/data/constants/video.test.js b/src/editors/data/constants/video.test.js
new file mode 100644
index 0000000000..cec43e1c31
--- /dev/null
+++ b/src/editors/data/constants/video.test.js
@@ -0,0 +1,65 @@
+import ISO6391 from 'iso-639-1';
+import { getLanguageName } from './video';
+
+describe('getLanguageName', () => {
+ let displayNamesSpy;
+
+ beforeEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('returns language name from Intl.DisplayNames for given locale', () => {
+ displayNamesSpy = jest.spyOn(global.Intl, 'DisplayNames').mockImplementation(() => ({
+ of: jest.fn().mockReturnValue('English'),
+ }));
+
+ expect(getLanguageName('en')).toBe('English');
+ expect(displayNamesSpy).toHaveBeenCalledWith(['en'], { type: 'language' });
+ });
+
+ it('falls back to ISO6391 when Intl.DisplayNames throws', () => {
+ jest.spyOn(global.Intl, 'DisplayNames').mockImplementation(() => {
+ throw new Error('Intl not supported');
+ });
+ jest.spyOn(ISO6391, 'getName').mockReturnValue('English');
+
+ expect(getLanguageName('en')).toBe('English');
+ });
+
+ it('falls back to code when both Intl.DisplayNames and ISO6391 fail', () => {
+ jest.spyOn(global.Intl, 'DisplayNames').mockImplementation(() => {
+ throw new Error('Intl not supported');
+ });
+ jest.spyOn(ISO6391, 'getName').mockReturnValue('');
+
+ expect(getLanguageName('xx')).toBe('xx');
+ });
+
+ it('returns empty string when langCode is missing', () => {
+ expect(getLanguageName('')).toBe('');
+ expect(getLanguageName(null)).toBe('');
+ expect(getLanguageName(undefined)).toBe('');
+ });
+
+ it('is case-insensitive for language codes', () => {
+ displayNamesSpy = jest.spyOn(global.Intl, 'DisplayNames').mockImplementation(() => ({
+ of: jest.fn().mockReturnValue('English'),
+ }));
+
+ expect(getLanguageName('EN')).toBe('English');
+ expect(getLanguageName('En')).toBe('English');
+ });
+
+ it('tries multiple locales in order', () => {
+ const mockOf = jest.fn()
+ .mockReturnValueOnce(null) // first locale returns null
+ .mockReturnValueOnce('Inglés'); // second locale works
+
+ displayNamesSpy = jest.spyOn(global.Intl, 'DisplayNames').mockImplementation(() => ({
+ of: mockOf,
+ }));
+
+ expect(getLanguageName('en', ['fr', 'es'])).toBe('Inglés');
+ expect(mockOf).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/editors/data/redux/video/selectors.js b/src/editors/data/redux/video/selectors.js
index 4829321835..d9b4c104c3 100644
--- a/src/editors/data/redux/video/selectors.js
+++ b/src/editors/data/redux/video/selectors.js
@@ -1,7 +1,7 @@
import { createSelector } from 'reselect';
import { keyStore } from '../../../utils';
-import { videoTranscriptLanguages } from '../../constants/video';
+import { openLanguagesDataSet } from '../../constants/video';
import { initialState } from './reducer';
// This 'module' self-import hack enables mocking during tests.
@@ -50,10 +50,10 @@ export const openLanguages = createSelector(
[module.simpleSelectors.transcripts],
(transcripts) => {
if (!transcripts) {
- return videoTranscriptLanguages;
+ return Object.keys(openLanguagesDataSet);
}
- const open = Object.keys(videoTranscriptLanguages).filter(
- (lang) => !transcripts.includes(lang),
+ const open = Object.keys(openLanguagesDataSet).filter(
+ (code) => !transcripts.includes(code),
);
return open;
},
diff --git a/src/editors/data/redux/video/selectors.test.js b/src/editors/data/redux/video/selectors.test.js
new file mode 100644
index 0000000000..e5551ae47c
--- /dev/null
+++ b/src/editors/data/redux/video/selectors.test.js
@@ -0,0 +1,29 @@
+import { openLanguages } from './selectors';
+import { openLanguagesDataSet } from '../../constants/video';
+
+describe('openLanguages selector', () => {
+ const allLangCodes = Object.keys(openLanguagesDataSet);
+
+ it('should return all languages when transcripts is undefined', () => {
+ const state = { transcripts: undefined };
+ const result = openLanguages.resultFunc(state.transcripts);
+ expect(result).toEqual(allLangCodes);
+ });
+
+ it('should return only languages not present in transcripts', () => {
+ const transcripts = [allLangCodes[0], allLangCodes[1]];
+ const state = { transcripts };
+ const result = openLanguages.resultFunc(state.transcripts);
+ const expected = allLangCodes.filter(
+ code => !transcripts.includes(code),
+ );
+ expect(result).toEqual(expected);
+ });
+
+ it('should return empty array if all languages are in transcripts', () => {
+ const transcripts = [...allLangCodes];
+ const state = { transcripts };
+ const result = openLanguages.resultFunc(state.transcripts);
+ expect(result).toEqual([]);
+ });
+});