Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create localization service #725

Merged
merged 10 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;