Skip to content

Commit

Permalink
Create localization service (#725)
Browse files Browse the repository at this point in the history
  • Loading branch information
rolfheij-sil authored Jan 23, 2024
2 parents 65bf15c + 4597266 commit 967633d
Show file tree
Hide file tree
Showing 7 changed files with 311 additions and 0 deletions.
4 changes: 4 additions & 0 deletions assets/localization/eng.json
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"
}
4 changes: 4 additions & 0 deletions assets/localization/fre.json
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"
}
3 changes: 3 additions & 0 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import networkObjectStatusService from '@shared/services/network-object-status.s
import { get } from '@shared/services/project-data-provider.service';
import { VerseRef } from '@sillsdev/scripture';
import { startNetworkObjectStatusService } from './services/network-object-status.service-host';
import { startLocalizationService } from './services/localization.service-host';

const PROCESS_CLOSE_TIME_OUT = 2000;

Expand Down Expand Up @@ -79,6 +80,8 @@ async function main() {
// For now, the dependency loop is broken by retrying 'getWebView' in a loop for a while.
await extensionHostService.start();

await startLocalizationService();

// TODO (maybe): Wait for signal from the extension host process that it is ready (except 'getWebView')
// We could then wait for the renderer to be ready and signal the extension host

Expand Down
105 changes: 105 additions & 0 deletions src/main/services/localization.service-host.test.tsx
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',
});
});
121 changes: 121 additions & 0 deletions src/main/services/localization.service-host.ts
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,
);
}
30 changes: 30 additions & 0 deletions src/shared/services/localization.service-model.ts
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';
44 changes: 44 additions & 0 deletions src/shared/services/localization.service.ts
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;

0 comments on commit 967633d

Please sign in to comment.