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

fix: update localization lookup #34

Merged
merged 5 commits into from
Apr 15, 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@

- `Coordinates` type could erroneously have a non-number type for `row`.
- Fix support for allowed types within payloads.
- Fix localization lookup.

### ♻️ Update

- Update layout and manifest references to propagate from [`@elgato/schemas`](https://github.com/elgatosf/schemas).

### ➡️ Migration

- Localizations keys are now indexed from the `Localization` node within the translation file.
- `PayloadObject<T>` replaced with `JsonObject`.
- JSON schemas have been relocated to a dedicated schemas package, [`@elgato/schemas`](https://github.com/elgatosf/schemas).

Expand Down
81 changes: 31 additions & 50 deletions src/plugin/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import fs, { Dirent } from "node:fs";
import path from "node:path";
import type { Manifest } from "../../api";
import { I18nProvider } from "../i18n";
import { logger } from "../logging";
import { LogLevel } from "../logging/log-level";
Expand All @@ -13,23 +12,20 @@ describe("I18nProvider", () => {
* Defines a set of mock resources.
*/
type MockTranslations = {
greeting?: string;
manifestOnly?: string;
englishOnly?: string;
frenchOnly?: string;
germanOnly?: string;
Localization: {
greeting?: string;
manifestOnly?: string;
englishOnly?: string;
frenchOnly?: string;
germanOnly?: string;
};
};

const mockedCwd = "c:\\temp";
const mockedResources = new Map<string, MockTranslations>();
mockedResources.set("de.json", { greeting: "Hello welt", germanOnly: "German" });
mockedResources.set("en.json", { greeting: "Hello world", englishOnly: "English" });
mockedResources.set("fr.json", { greeting: "Bonjour le monde", frenchOnly: "French" });

const mockedManifest = {
greeting: "This should never be used",
manifestOnly: "Manifest"
} as unknown as Manifest;
mockedResources.set("de.json", { Localization: { greeting: "Hello welt", germanOnly: "German" } });
mockedResources.set("en.json", { Localization: { greeting: "Hello world", englishOnly: "English" } });
mockedResources.set("fr.json", { Localization: { greeting: "Bonjour le monde", frenchOnly: "French" } });

let scopedLogger!: Logger;

Expand All @@ -43,6 +39,8 @@ describe("I18nProvider", () => {
jest.spyOn(process, "cwd").mockReturnValue(mockedCwd);
});

afterEach(() => jest.resetAllMocks());

/**
* Asserts {@link I18nProvider} uses a scoped {@link Logger}.
*/
Expand All @@ -54,7 +52,7 @@ describe("I18nProvider", () => {
const createScopeSpy = jest.spyOn(logger, "createScope");

// Act.
new I18nProvider("en", mockedManifest, logger);
new I18nProvider("en", logger);

// Assert.
expect(createScopeSpy).toHaveBeenCalledTimes(1);
Expand All @@ -70,7 +68,8 @@ describe("I18nProvider", () => {
const readFileSyncSpy = jest.spyOn(fs, "readFileSync").mockImplementation(() => "{}");

// Act.
new I18nProvider("en", mockedManifest, logger);
const i18n = new I18nProvider("en", logger);
i18n.translate("test");

// Assert.
expect(readFileSyncSpy).toHaveBeenCalledTimes(6);
Expand All @@ -86,25 +85,6 @@ describe("I18nProvider", () => {
expect(readFileSyncSpy).not.toHaveBeenCalledWith(path.join(process.cwd(), "manifest.json"), opts);
});

/**
* Asserts {@link I18nProvider} merges the manifest (resources) into the custom English resources.
*/
it("merges manifest into English", () => {
// Arrange.
jest.spyOn(fs, "readdirSync").mockReturnValue(["de.json", "en.json", "fr.json"] as unknown[] as Dirent[]);
jest.spyOn(fs, "readFileSync").mockImplementation((path) => JSON.stringify(mockedResources.get(path as string)));

const i18n = new I18nProvider("en", mockedManifest, logger);

// Act.
const greeting = i18n.translate("greeting");
const manifestOnly = i18n.translate("manifestOnly");

// Assert.
expect(greeting).toBe("Hello world");
expect(manifestOnly).toBe("Manifest");
});

/**
* Asserts {@link I18nProvider} correctly resorts to default language.
*/
Expand All @@ -113,57 +93,55 @@ describe("I18nProvider", () => {
jest.spyOn(fs, "readdirSync").mockReturnValue(["de.json", "en.json", "fr.json"] as unknown[] as Dirent[]);
jest.spyOn(fs, "readFileSync").mockImplementation((path) => JSON.stringify(mockedResources.get(path as string)));

const i18n = new I18nProvider("de", mockedManifest, logger);
const i18n = new I18nProvider("de", logger);

// Act.
const greeting = i18n.translate("greeting");
const englishOnly = i18n.translate("englishOnly");
const manifestOnly = i18n.translate("manifestOnly");

// Assert.
expect(greeting).toBe("Hello welt");
expect(englishOnly).toBe("English");
expect(manifestOnly).toBe("Manifest");
});

/**
* Asserts {@link I18nProvider} returns an empty string when the resource could not be found in either the current resource, or the default.
* Asserts {@link I18nProvider} returns the key when the resource could not be found in either the current resource, or the default.
*/
it("returns empty string for unknown key (logMissingKey: true)", () => {
it("returns the key for an unknown resource (logMissingKey: true)", () => {
// Arrange.
jest.spyOn(fs, "readdirSync").mockReturnValue([]);
jest.spyOn(fs, "readFileSync").mockReturnValue("{}");
const spyOnWarn = jest.spyOn(scopedLogger, "warn");

const i18n = new I18nProvider("en", mockedManifest, logger);
const i18n = new I18nProvider("en", logger);

// Act.
i18n.logMissingKey = true;
const result = i18n.translate("hello");

// Assert.
expect(result).toBe("");
expect(result).toBe("hello");
expect(spyOnWarn).toHaveBeenCalledTimes(1);
expect(spyOnWarn).toHaveBeenCalledWith("Missing translation: hello");
});

/**
* Asserts {@link I18nProvider} returns an empty string when the resource could not be found in either the current resource, or the default.
* Asserts {@link I18nProvider} returns the key when the resource could not be found in either the current resource, or the default.
*/
it("returns empty string for unknown key (logMissingKey: false)", () => {
it("returns the key for an unknown resource (logMissingKey: false)", () => {
// Arrange.
jest.spyOn(fs, "readdirSync").mockReturnValue([]);
jest.spyOn(fs, "readFileSync").mockReturnValue("{}");
const spyOnWarn = jest.spyOn(scopedLogger, "warn");

const i18n = new I18nProvider("en", mockedManifest, logger);
const i18n = new I18nProvider("en", logger);

// Act.
i18n.logMissingKey = false;
const result = i18n.translate("hello");

// Assert.
expect(result).toBe("");
expect(result).toBe("hello");
expect(spyOnWarn).toHaveBeenCalledTimes(0);
});

Expand All @@ -177,7 +155,8 @@ describe("I18nProvider", () => {
const spyOnError = jest.spyOn(scopedLogger, "error");

// Act.
new I18nProvider("en", mockedManifest, logger);
const i18n = new I18nProvider("en", logger);
i18n.translate("test");

// Assert.
expect(spyOnError).toHaveBeenCalledTimes(1);
Expand All @@ -192,13 +171,15 @@ describe("I18nProvider", () => {
jest.spyOn(fs, "readdirSync").mockReturnValue(["en.json"] as unknown[] as Dirent[]);
jest.spyOn(fs, "readFileSync").mockReturnValue(
JSON.stringify({
parent: {
child: "Hello world"
Localization: {
parent: {
child: "Hello world"
}
}
})
);

const i18n = new I18nProvider("en", mockedManifest, logger);
const i18n = new I18nProvider("en", logger);

// Act.
const result = i18n.translate("parent.child");
Expand Down
61 changes: 31 additions & 30 deletions src/plugin/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import file from "node:fs";
import path from "node:path";

import { supportedLanguages, type Language, type Manifest } from "../api";
import { supportedLanguages, type Language } from "../api";
import { JsonObject } from "../common/json";
import { get } from "./common/utils";
import { Logger } from "./logging";

Expand All @@ -20,9 +21,9 @@ export class I18nProvider {
private static readonly DEFAULT_LANGUAGE: Language = "en";

/**
* Collection of loaded locales and their translations.
* Private backing field for {@link I18nProvider.locales}.
*/
private readonly locales = new Map<Language, unknown>();
private _locales: Map<Language, JsonObject> | undefined;

/**
* Logger scoped to this class.
Expand All @@ -32,64 +33,62 @@ export class I18nProvider {
/**
* Initializes a new instance of the {@link I18nProvider} class.
* @param language The default language to be used when retrieving translations for a given key.
* @param manifest Manifest that accompanies the plugin.
* @param logger Logger responsible for capturing log entries.
*/
constructor(
private readonly language: Language,
manifest: Manifest,
logger: Logger
) {
this.logger = logger.createScope("I18nProvider");
this.loadLocales(manifest);
}

/**
* Gets the translation for the specified {@link key} from the resources defined for {@link language}. When the key is not found, the default language is checked.
* @param key Key that represents the translation.
* @param language Optional language to get the translation for; otherwise the default language.
* @returns The translation; otherwise an empty string.
* Collection of loaded locales and their translations.
* @returns The locales that contains the translations.
*/
public translate(key: string, language: Language | undefined = this.language): string {
const translation = this.translateOrDefault(key, language);

if (translation === undefined && this.logMissingKey) {
this.logger.warn(`Missing translation: ${key}`);
private get locales(): Map<Language, JsonObject> {
if (this._locales !== undefined) {
return this._locales;
}

return translation || "";
}

/**
* Loads all known locales from the current working directory.
* @param manifest Manifest that accompanies the plugin.
*/
private loadLocales(manifest: Manifest): void {
const locales = new Map<Language, JsonObject>();
for (const filePath of file.readdirSync(process.cwd())) {
const { ext, name } = path.parse(filePath);
const lng = name as Language;

if (ext.toLowerCase() == ".json" && supportedLanguages.includes(lng)) {
const contents = this.readFile(filePath);
if (contents !== undefined) {
this.locales.set(lng, contents);
locales.set(lng, contents);
}
}
}

// Merge the manifest into the default language, prioritizing explicitly defined resources.
this.locales.set(I18nProvider.DEFAULT_LANGUAGE, {
...manifest,
...(this.locales.get(I18nProvider.DEFAULT_LANGUAGE) || {})
});
return (this._locales = locales);
}

/**
* Gets the translation for the specified {@link key} from the resources defined for {@link language}. When the key is not found, the default language is checked.
* @param key Key that represents the translation.
* @param language Optional language to get the translation for; otherwise the default language.
* @returns The translation; otherwise an empty string.
*/
public translate(key: string, language: Language | undefined = this.language): string {
const translation = this.translateOrDefault(key, language);

if (translation === undefined && this.logMissingKey) {
this.logger.warn(`Missing translation: ${key}`);
}

return translation || key;
}

/**
* Reads the contents of the {@link filePath} and parses it as JSON.
* @param filePath File path to read.
* @returns Parsed object; otherwise `undefined`.
*/
private readFile(filePath: string): unknown | undefined {
private readFile(filePath: string): JsonObject | undefined {
try {
const contents = file.readFileSync(filePath, { flag: "r" })?.toString();
return JSON.parse(contents);
Expand All @@ -105,6 +104,8 @@ export class I18nProvider {
* @returns The resource; otherwise the default language's resource, or `undefined`.
*/
private translateOrDefault(key: string, language: Language = this.language): string | undefined {
key = `Localization.${key}`;

// When the language and default are the same, only check the language.
if (language === I18nProvider.DEFAULT_LANGUAGE) {
return get(key, this.locales.get(language))?.toString();
Expand Down
2 changes: 1 addition & 1 deletion src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const streamDeck = {
* @returns Internalization provider.
*/
get i18n(): I18nProvider {
return (i18n ??= new I18nProvider(this.info.application.language, this.manifest, this.logger));
return (i18n ??= new I18nProvider(this.info.application.language, this.logger));
},

/**
Expand Down
2 changes: 0 additions & 2 deletions src/plugin/ui/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,6 @@ class ActionWithRoutes extends SingletonAction {
*/
@route("/characters")
public getCharacters(req: MessageRequest, res: MessageResponder): Promise<string[]> {
console.log("Called spy async");
this.spyOnGetCharacters(req, res);
return Promise.resolve(["Anduin", "Sylvanas", "Thrall"]);
}
Expand All @@ -241,7 +240,6 @@ class ActionWithRoutes extends SingletonAction {
*/
@route("/characters-sync")
public getCharactersSync(req: MessageRequest, res: MessageResponder): string[] {
console.log("Called spy sync");
this.spyOnGetCharactersSync(req, res);
return ["Mario", "Luigi", "Peach"];
}
Expand Down