-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
311 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
{ | ||
"some_localization_key": "This is the English text for %some_localization_key%.", | ||
"submitButton": "Submit" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
{ | ||
"some_localization_key": "Ceci est le texte en français pour %some_localization_key%.", | ||
"submitButton": "Soumettre" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
import { testingLocalizationService } from '@main/services/localization.service-host'; | ||
|
||
const MOCK_FILES: { [uri: string]: string } = { | ||
'resources://assets/localization/eng.json': `{ | ||
"some_localization_key": "This is the English text for %some_localization_key%.", | ||
"submitButton": "Submit" | ||
}`, | ||
'resources://assets/localization/fre.json': `{ | ||
"some_localization_key": "Ceci est le texte en français pour %some_localization_key%.", | ||
"submitButton": "Soumettre" | ||
}`, | ||
}; | ||
jest.mock('@node/services/node-file-system.service', () => ({ | ||
readDir: () => { | ||
const entries: Readonly<{ | ||
file: string[]; | ||
directory: string[]; | ||
unknown: string[]; | ||
}> = { | ||
file: Object.keys(MOCK_FILES), | ||
directory: [], | ||
unknown: [], | ||
}; | ||
return Promise.resolve(entries); | ||
}, | ||
readFileText: (uri: string) => { | ||
const stringContentsOfFile: string = MOCK_FILES[uri]; | ||
return Promise.resolve(stringContentsOfFile); | ||
}, | ||
})); | ||
|
||
test('Correct localized value returned to match single localizeKey', async () => { | ||
const LOCALIZE_KEY: string = 'submitButton'; | ||
const response = await testingLocalizationService.localizationService.getLocalizedString( | ||
LOCALIZE_KEY, | ||
'fre', | ||
); | ||
expect(response).toEqual('Soumettre'); | ||
}); | ||
|
||
test('Correct localized values returned to match array of localizeKeys', async () => { | ||
const LOCALIZE_KEYS: string[] = ['submitButton', 'some_localization_key']; | ||
const response = await testingLocalizationService.localizationService.getLocalizedStrings( | ||
LOCALIZE_KEYS, | ||
'fre', | ||
); | ||
expect(response).toEqual({ | ||
submitButton: 'Soumettre', | ||
some_localization_key: 'Ceci est le texte en français pour %some_localization_key%.', | ||
}); | ||
}); | ||
|
||
test('Error returned with localizeKey that does not exist', async () => { | ||
const LOCALIZE_KEY = 'the_wrong_key'; | ||
await expect( | ||
testingLocalizationService.localizationService.getLocalizedString(LOCALIZE_KEY, 'fre'), | ||
).rejects.toThrow('Missing/invalid localization data'); | ||
}); | ||
|
||
test('Error returned with localizeKeys that do not exist', async () => { | ||
const LOCALIZE_KEYS = ['the_wrong_key', 'the_other_wrong_key']; | ||
await expect( | ||
testingLocalizationService.localizationService.getLocalizedStrings(LOCALIZE_KEYS, 'fre'), | ||
).rejects.toThrow('Missing/invalid localization data'); | ||
}); | ||
|
||
test('Error returned with localizeKeys where one exists but the other does not', async () => { | ||
const LOCALIZE_KEYS = ['submitButton', 'the_wrong_key']; | ||
await expect( | ||
testingLocalizationService.localizationService.getLocalizedStrings(LOCALIZE_KEYS, 'fre'), | ||
).rejects.toThrow('Missing/invalid localization data'); | ||
}); | ||
|
||
test('Error returned with localizeKey and incorrect language code', async () => { | ||
const LOCALIZE_KEY = 'submitButton'; // irrelevant because it will throw for language code before it accesses key/value pairs | ||
await expect( | ||
testingLocalizationService.localizationService.getLocalizedString(LOCALIZE_KEY, 'XXX'), | ||
).rejects.toThrow('Missing/invalid localization data'); | ||
}); | ||
|
||
test('Error returned with localizeKeys and incorrect language code', async () => { | ||
const LOCALIZE_KEYS = ['submitButton', 'some_localization_key']; // irrelevant because it will throw for language code before it accesses key/value pairs | ||
await expect( | ||
testingLocalizationService.localizationService.getLocalizedStrings(LOCALIZE_KEYS, 'XXX'), | ||
).rejects.toThrow('Missing/invalid localization data'); | ||
}); | ||
|
||
test('Default language is english when no language provided with localizeKey', async () => { | ||
const LOCALIZE_KEY = 'submitButton'; | ||
const response = await testingLocalizationService.localizationService.getLocalizedString( | ||
LOCALIZE_KEY, | ||
); | ||
await expect(response).toEqual('Submit'); | ||
}); | ||
|
||
test('Default language is english when no language provided with localizeKeys', async () => { | ||
const LOCALIZE_KEYS = ['submitButton', 'some_localization_key']; | ||
const response = await testingLocalizationService.localizationService.getLocalizedStrings( | ||
LOCALIZE_KEYS, | ||
); | ||
expect(response).toEqual({ | ||
some_localization_key: 'This is the English text for %some_localization_key%.', | ||
submitButton: 'Submit', | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
import { | ||
LocalizationServiceType, | ||
localizationServiceNetworkObjectName, | ||
LocalizationData, | ||
} from '@shared/services/localization.service-model'; | ||
import networkObjectService from '@shared/services/network-object.service'; | ||
import * as nodeFS from '@node/services/node-file-system.service'; | ||
import { deserialize } from 'platform-bible-utils'; | ||
import logger from '@shared/services/logger.service'; | ||
import { joinUriPaths } from '@node/utils/util'; | ||
|
||
const LOCALIZATION_ROOT_URI = joinUriPaths('resources://', 'assets', 'localization'); | ||
const LANGUAGE_CODE_REGEX = /\/([a-zA-Z]+)\.json$/; | ||
const DEFAULT_LANGUAGE = 'eng'; | ||
|
||
function getLanguageCodeFromUri(uriToMatch: string): string { | ||
const match = uriToMatch.match(LANGUAGE_CODE_REGEX); | ||
if (!match) throw new Error('Localization service - No match for language code'); | ||
return match[1]; | ||
} | ||
|
||
/** Convert contents of a specific localization json file to an object */ | ||
function convertToLocalizationData(jsonString: string, languageCode: string): LocalizationData { | ||
const localizationData: LocalizationData = deserialize(jsonString); | ||
if (typeof localizationData !== 'object') | ||
throw new Error(`Localization data for language '${languageCode}' is invalid`); | ||
return localizationData; | ||
} | ||
|
||
async function getLocalizedFileUris(): Promise<string[]> { | ||
const entries = await nodeFS.readDir(LOCALIZATION_ROOT_URI); | ||
if (!entries) throw new Error('No entries found in localization folder'); | ||
return entries.file; | ||
} | ||
|
||
/** Map of ISO 639-2 code to localized values for that language */ | ||
const languageLocalizedData = new Map<string, LocalizationData>(); | ||
|
||
/** Load the contents of all localization files from disk */ | ||
async function loadAllLocalizationData(): Promise<Map<string, LocalizationData>> { | ||
languageLocalizedData.clear(); | ||
const localizeFileUris = await getLocalizedFileUris(); | ||
|
||
await Promise.all( | ||
localizeFileUris.map(async (uri) => { | ||
try { | ||
const localizeFileString = await nodeFS.readFileText(uri); | ||
const languageCode = getLanguageCodeFromUri(uri); | ||
languageLocalizedData.set( | ||
languageCode, | ||
convertToLocalizationData(localizeFileString, languageCode), | ||
); | ||
} catch (error) { | ||
logger.warn(error); | ||
} | ||
}), | ||
); | ||
return languageLocalizedData; | ||
} | ||
|
||
let initializationPromise: Promise<void>; | ||
async function initialize(): Promise<void> { | ||
if (!initializationPromise) { | ||
initializationPromise = new Promise<void>((resolve, reject) => { | ||
const executor = async () => { | ||
try { | ||
await loadAllLocalizationData(); | ||
resolve(); | ||
} catch (error) { | ||
reject(error); | ||
} | ||
}; | ||
executor(); | ||
}); | ||
} | ||
return initializationPromise; | ||
} | ||
|
||
async function getLocalizedString(localizeKey: string, language: string = DEFAULT_LANGUAGE) { | ||
await initialize(); | ||
const languageData = languageLocalizedData.get(language); | ||
|
||
if (!languageData || !languageData[localizeKey]) | ||
throw new Error('Missing/invalid localization data'); | ||
return languageData[localizeKey]; | ||
} | ||
|
||
async function getLocalizedStrings(localizeKeys: string[], language: string = DEFAULT_LANGUAGE) { | ||
await initialize(); | ||
const languageData = languageLocalizedData.get(language); | ||
|
||
if (!languageData) throw new Error('Missing/invalid localization data'); | ||
return Object.fromEntries( | ||
localizeKeys.map((key) => { | ||
if (!languageData[key]) throw new Error('Missing/invalid localization data'); | ||
return [key, languageData[key]]; | ||
}), | ||
); | ||
} | ||
|
||
const localizationService: LocalizationServiceType = { | ||
getLocalizedString, | ||
getLocalizedStrings, | ||
}; | ||
|
||
/** This is an internal-only export for testing purposes, and should not be used in development */ | ||
export const testingLocalizationService = { | ||
localizationService, | ||
}; | ||
|
||
/** Register the network object that backs the PAPI localization service */ | ||
// This doesn't really represent this service module, so we're not making it default. To use this | ||
// service, you should use `localization.service.ts` | ||
// eslint-disable-next-line import/prefer-default-export | ||
export async function startLocalizationService(): Promise<void> { | ||
await initialize(); | ||
await networkObjectService.set<LocalizationServiceType>( | ||
localizationServiceNetworkObjectName, | ||
localizationService, | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
/** | ||
* JSDOC SOURCE localizationService | ||
* | ||
* Provides localization data for UI | ||
*/ | ||
export interface LocalizationServiceType { | ||
/** | ||
* Look up localized string for specific localizeKey | ||
* | ||
* @param localizeKey String key that corresponds to a localized value | ||
* @param language ISO 639-2 code for the language, defaults to 'eng' if unspecified | ||
* @returns Localized string | ||
*/ | ||
getLocalizedString: (localizeKey: string, language?: string) => Promise<string>; | ||
/** | ||
* Look up localized strings for all localizeKeys provided | ||
* | ||
* @param localizeKeys Array of localize keys that correspond to localized values | ||
* @param language ISO 639-2 code for the language, defaults to 'eng' if unspecified | ||
* @returns Object whose keys are localizeKeys and values are localized strings | ||
*/ | ||
getLocalizedStrings: ( | ||
localizeKeys: string[], | ||
language?: string, | ||
) => Promise<{ [localizeKey: string]: string }>; | ||
} | ||
|
||
export type LocalizationData = { [localizeKey: string]: string }; | ||
|
||
export const localizationServiceNetworkObjectName = 'LocalizationService'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import { | ||
localizationServiceNetworkObjectName, | ||
LocalizationServiceType, | ||
} from '@shared/services/localization.service-model'; | ||
import networkObjectService from '@shared/services/network-object.service'; | ||
|
||
let networkObject: LocalizationServiceType; | ||
let initializationPromise: Promise<void>; | ||
async function initialize(): Promise<void> { | ||
if (!initializationPromise) { | ||
initializationPromise = new Promise<void>((resolve, reject) => { | ||
const executor = async () => { | ||
try { | ||
const localLocalizationService = await networkObjectService.get<LocalizationServiceType>( | ||
localizationServiceNetworkObjectName, | ||
); | ||
if (!localLocalizationService) | ||
throw new Error( | ||
`${localizationServiceNetworkObjectName} is not available as a network object`, | ||
); | ||
networkObject = localLocalizationService; | ||
resolve(); | ||
} catch (error) { | ||
reject(error); | ||
} | ||
}; | ||
executor(); | ||
}); | ||
} | ||
return initializationPromise; | ||
} | ||
|
||
const localizationService: LocalizationServiceType = { | ||
getLocalizedString: async (localizeKey: string, language?: string) => { | ||
await initialize(); | ||
return networkObject.getLocalizedString(localizeKey, language); | ||
}, | ||
getLocalizedStrings: async (localizeKeys: string[], language?: string) => { | ||
await initialize(); | ||
return networkObject.getLocalizedStrings(localizeKeys, language); | ||
}, | ||
}; | ||
|
||
export default localizationService; |