Repository: st3v3nmw/obsidian-spaced-repetition
Pull request #315 (2021-10-31 04:01:42)

Changing interpolate to an optional arg of t
While we can't change the existing Obsidian API that sometimes extends string with new methods, it's not necessary to extend string when we only need interpolation when calling t for translated strings...

This PR makes it a small bit easier to test scheduling.ts by avoiding the need to provide a global interpolate function for every string.

Provided some unit tests for translation and interpolation, just to make sure it continued to work as expected.

LouisStAmour: Also note that I didn't update how we get locale - right now, if locale happens to change, it won't be reflected in calls to t unless the entire import is reset and reloaded/re-executed from scratch. That's why the tests for the translate function look a bit messy, it's a sign that there's a small bit of tech debt here.

Patch:
diff --git a/jest.config.js b/jest.config.js
index 8e85f8c..4663d9b 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -3,6 +3,7 @@ module.exports = {
     preset: "ts-jest",
     testEnvironment: "jsdom",
     moduleNameMapper: {
-        "src/(.*)": "<rootDir>/src/$1"
-    }
-};
\ No newline at end of file
+        "src/(.*)": "<rootDir>/src/$1",
+    },
+    roots: ["<rootDir>/src/", "<rootDir>/tests/"]
+};
diff --git a/src/lang/helpers.ts b/src/lang/helpers.ts
index 0b57396..80a2f4a 100644
--- a/src/lang/helpers.ts
+++ b/src/lang/helpers.ts
@@ -53,10 +53,23 @@ const localeMap: { [k: string]: Partial<typeof en> } = {
 
 const locale = localeMap[moment.locale()];
 
-export function t(str: keyof typeof en): string {
+// https://stackoverflow.com/a/41015840/
+function interpolate(str: string, params: Record<string, unknown>): string {
+    const names: string[] = Object.keys(params);
+    const vals: unknown[] = Object.values(params);
+    return new Function(...names, `return \`${str}\`;`)(...vals);
+}
+
+export function t(str: keyof typeof en, params?: Record<string, unknown>): string {
     if (!locale) {
         console.error("Error: SRS locale not found", moment.locale());
     }
 
-    return (locale && locale[str]) || en[str];
+    const result = (locale && locale[str]) || en[str];
+
+    if(params) {
+        return interpolate(result, params);
+    }
+
+    return result;
 }
diff --git a/src/main.ts b/src/main.ts
index 6c1adee..1598135 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -44,13 +44,6 @@ export interface LinkStat {
     linkCount: number;
 }
 
-// https://stackoverflow.com/a/41015840/
-String.prototype.interpolate = function (params: Record<string, unknown>) {
-    const names: string[] = Object.keys(params);
-    const vals: unknown[] = Object.values(params);
-    return new Function(...names, `return \`${this}\`;`)(...vals);
-};
-
 export default class SRPlugin extends Plugin {
     private statusBar: HTMLElement;
     private reviewQueueView: ReviewQueueListView;
@@ -380,14 +373,14 @@ export default class SRPlugin extends Plugin {
         if (this.data.settings.showDebugMessages) {
             console.log(
                 "SR: " +
-                    t("SYNC_TIME_TAKEN").interpolate({
+                    t("SYNC_TIME_TAKEN", {
                         t: Date.now() - now.valueOf(),
                     })
             );
         }
 
         this.statusBar.setText(
-            t("STATUS_BAR").interpolate({
+            t("STATUS_BAR", {
                 dueNotesCount: this.dueNotesCount,
                 dueFlashcardsCount: this.deckTree.dueFlashcardsCount,
             })
@@ -548,7 +541,7 @@ export default class SRPlugin extends Plugin {
 
     async reviewNextNote(deckKey: string): Promise<void> {
         if (!Object.prototype.hasOwnProperty.call(this.reviewDecks, deckKey)) {
-            new Notice(t("NO_DECK_EXISTS").interpolate({ deckName: deckKey }));
+            new Notice(t("NO_DECK_EXISTS", { deckName: deckKey }));
             return;
         }
 
diff --git a/src/scheduling.ts b/src/scheduling.ts
index 2cf11f2..4c3ea8f 100644
--- a/src/scheduling.ts
+++ b/src/scheduling.ts
@@ -104,11 +104,11 @@ export function textInterval(interval: number, isMobile: boolean): string {
         else return `${y}y`;
     } else {
         if (interval < 30) {
-            return t("DAYS_STR_IVL").interpolate({ interval });
+            return t("DAYS_STR_IVL", { interval });
         } else if (interval < 365) {
-            return t("MONTHS_STR_IVL").interpolate({ interval: m });
+            return t("MONTHS_STR_IVL", { interval: m });
         } else {
-            return t("YEARS_STR_IVL").interpolate({ interval: y });
+            return t("YEARS_STR_IVL", { interval: y });
         }
     }
 }
diff --git a/src/settings.ts b/src/settings.ts
index 07e94e2..2b1ec3b 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -93,7 +93,7 @@ export class SRSettingTab extends PluginSettingTab {
 
         containerEl.createDiv().innerHTML = "<h2>" + t("SETTINGS_HEADER") + "</h2>";
 
-        containerEl.createDiv().innerHTML = t("CHECK_WIKI").interpolate({
+        containerEl.createDiv().innerHTML = t("CHECK_WIKI", {
             wiki_url: "https://github.com/st3v3nmw/obsidian-spaced-repetition/wiki",
         });
 
@@ -449,7 +449,7 @@ export class SRSettingTab extends PluginSettingTab {
             });
 
         containerEl.createDiv().innerHTML = "<h3>" + t("ALGORITHM") + "</h3>";
-        containerEl.createDiv().innerHTML = t("CHECK_ALGORITHM_WIKI").interpolate({
+        containerEl.createDiv().innerHTML = t("CHECK_ALGORITHM_WIKI", {
             algo_url:
                 "https://github.com/st3v3nmw/obsidian-spaced-repetition/wiki/Spaced-Repetition-Algorithm",
         });
diff --git a/src/stats-modal.ts b/src/stats-modal.ts
index 76dcb81..802d375 100644
--- a/src/stats-modal.ts
+++ b/src/stats-modal.ts
@@ -91,7 +91,7 @@ export class StatsModal extends Modal {
             "\tstacked: true\n" +
             "````\n" +
             "\n<div style='text-align:center'>" +
-            t("REVIEWS_PER_DAY").interpolate({ avg: (scheduledCount / maxN).toFixed(1) }) +
+            t("REVIEWS_PER_DAY", { avg: (scheduledCount / maxN).toFixed(1) }) +
             "</div>";
 
         maxN = Math.max(...getKeysPreserveType(cardStats.intervals));
@@ -140,7 +140,7 @@ export class StatsModal extends Modal {
             "\tstacked: true\n" +
             "````\n" +
             "\n<div style='text-align:center'>" +
-            t("INTERVALS_SUMMARY").interpolate({
+            t("INTERVALS_SUMMARY", {
                 avg: average_interval,
                 longest: longest_interval,
             }) +
@@ -179,7 +179,7 @@ export class StatsModal extends Modal {
             "\tstacked: true\n" +
             "````\n" +
             "\n<div style='text-align:center'>" +
-            t("EASES_SUMMARY").interpolate({ avgEase: average_ease }) +
+            t("EASES_SUMMARY", { avgEase: average_ease }) +
             "</div>";
 
         // Add card types
@@ -206,7 +206,7 @@ export class StatsModal extends Modal {
             "\tlabelColors: true\n" +
             "```\n" +
             "\n<div style='text-align:center'>" +
-            t("CARD_TYPES_SUMMARY").interpolate({ totalCardsCount }) +
+            t("CARD_TYPES_SUMMARY", { totalCardsCount }) +
             "</div>";
 
         MarkdownRenderer.renderMarkdown(text, contentEl, "", this.plugin);


Test patch:
diff --git a/tests/__mocks__/obsidian.js b/tests/__mocks__/obsidian.js
new file mode 100644
index 0000000..5ac7bca
--- /dev/null
+++ b/tests/__mocks__/obsidian.js
@@ -0,0 +1,5 @@
+module.exports = {
+    moment: {
+        locale: jest.fn(()=>"en")
+    }
+};
diff --git a/tests/lang/helpers.test.ts b/tests/lang/helpers.test.ts
new file mode 100644
index 0000000..5730c5a
--- /dev/null
+++ b/tests/lang/helpers.test.ts
@@ -0,0 +1,43 @@
+test("Test translation without interpolation in English", () => {
+    jest.isolateModules(() => {
+        const { moment } = require("obsidian");
+        const mockLocale = moment.locale as jest.MockedFunction<() => string>;
+        mockLocale.mockImplementation(() => 'en');
+        const {t} = require("src/lang/helpers");
+        expect(t("HARD")).toEqual("Hard");
+    });
+});
+
+test("Test translation without interpolation in German", () => {
+    jest.isolateModules(() => {
+        const { moment } = require("obsidian");
+        const mockLocale = moment.locale as jest.MockedFunction<() => string>;
+        mockLocale.mockImplementation(() => 'de');
+        const {t} = require("src/lang/helpers");
+        expect(t("HARD")).toEqual("Schwer");
+    });
+});
+
+test("Test translation with interpolation in English", () => {
+    jest.isolateModules(() => {
+        const { moment } = require("obsidian");
+        const mockLocale = moment.locale as jest.MockedFunction<() => string>;
+        mockLocale.mockImplementation(() => 'en');
+        const {t} = require("src/lang/helpers");
+        expect(
+            t("STATUS_BAR", {dueNotesCount: 1, dueFlashcardsCount: 2})
+        ).toEqual("Review: 1 notes(s), 2 card(s) due");
+    });
+});
+
+test("Test translation with interpolation in German", () => {
+    jest.isolateModules(() => {
+        const { moment } = require("obsidian");
+        const mockLocale = moment.locale as jest.MockedFunction<() => string>;
+        mockLocale.mockImplementation(() => 'de');
+        const {t} = require("src/lang/helpers");
+        expect(
+            t("STATUS_BAR", {dueNotesCount: 1, dueFlashcardsCount: 2})
+        ).toEqual("Wiederholung: 1 Notiz(en), 2 Karte(n) anstehend");
+    });
+});