From 8a2767ef4041c67bce58ff7aa7865823262cc6bd Mon Sep 17 00:00:00 2001 From: Nolway Date: Wed, 8 Dec 2021 01:34:50 +0100 Subject: [PATCH] Implement Translator: i18n system --- front/.eslintrc.js | 5 +- front/dist/resources/translations/.gitignore | 1 + front/package.json | 1 + front/src/Enum/EnvironmentVariable.ts | 2 + front/src/Phaser/Game/GameManager.ts | 14 +- front/src/Phaser/Login/EntryScene.ts | 73 ++++---- front/src/Translator/TranslationCompiler.ts | 72 ++++++++ front/src/Translator/Translator.ts | 170 +++++++++++++++++++ front/src/Utils/Cookies.ts | 20 +++ front/src/define-plugin.d.ts | 2 + front/translations/en-US/index.en-US.json | 5 + front/translations/en-US/test.en-US.json | 5 + front/translations/fr-FR/index.fr-FR.json | 5 + front/translations/fr-FR/test.fr-FR.json | 5 + front/webpack.config.ts | 39 ++++- front/yarn.lock | 21 ++- 16 files changed, 393 insertions(+), 47 deletions(-) create mode 100644 front/dist/resources/translations/.gitignore create mode 100644 front/src/Translator/TranslationCompiler.ts create mode 100644 front/src/Translator/Translator.ts create mode 100644 front/src/Utils/Cookies.ts create mode 100644 front/src/define-plugin.d.ts create mode 100644 front/translations/en-US/index.en-US.json create mode 100644 front/translations/en-US/test.en-US.json create mode 100644 front/translations/fr-FR/index.fr-FR.json create mode 100644 front/translations/fr-FR/test.fr-FR.json diff --git a/front/.eslintrc.js b/front/.eslintrc.js index ed94b3b2..fa57ebf4 100644 --- a/front/.eslintrc.js +++ b/front/.eslintrc.js @@ -8,7 +8,7 @@ module.exports = { "extends": [ "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking" + "plugin:@typescript-eslint/recommended-requiring-type-checking", ], "globals": { "Atomics": "readonly", @@ -23,7 +23,7 @@ module.exports = { }, "plugins": [ "@typescript-eslint", - "svelte3" + "svelte3", ], "overrides": [ { @@ -33,6 +33,7 @@ module.exports = { ], "rules": { "no-unused-vars": "off", + "eol-last": ["error", "always"], "@typescript-eslint/no-explicit-any": "error", "no-throw-literal": "error", // TODO: remove those ignored rules and write a stronger code! diff --git a/front/dist/resources/translations/.gitignore b/front/dist/resources/translations/.gitignore new file mode 100644 index 00000000..a6c57f5f --- /dev/null +++ b/front/dist/resources/translations/.gitignore @@ -0,0 +1 @@ +*.json diff --git a/front/package.json b/front/package.json index 4a4e78f6..c7b819bd 100644 --- a/front/package.json +++ b/front/package.json @@ -21,6 +21,7 @@ "html-webpack-plugin": "^5.3.1", "jasmine": "^3.5.0", "lint-staged": "^11.0.0", + "merge-jsons-webpack-plugin": "^2.0.1", "mini-css-extract-plugin": "^1.6.0", "node-polyfill-webpack-plugin": "^1.1.2", "npm-run-all": "^4.1.5", diff --git a/front/src/Enum/EnvironmentVariable.ts b/front/src/Enum/EnvironmentVariable.ts index 76b4c8af..07d4ff8e 100644 --- a/front/src/Enum/EnvironmentVariable.ts +++ b/front/src/Enum/EnvironmentVariable.ts @@ -25,6 +25,7 @@ export const POSTHOG_API_KEY: string = (process.env.POSTHOG_API_KEY as string) | export const POSTHOG_URL = process.env.POSTHOG_URL || undefined; export const DISABLE_ANONYMOUS: boolean = process.env.DISABLE_ANONYMOUS === "true"; export const OPID_LOGIN_SCREEN_PROVIDER = process.env.OPID_LOGIN_SCREEN_PROVIDER; +const FALLBACK_LANGUAGE: string = process.env.FALLBACK_LANGUAGE || "en-US"; export const isMobile = (): boolean => window.innerWidth <= 800 || window.innerHeight <= 600; @@ -44,4 +45,5 @@ export { TURN_PASSWORD, JITSI_URL, JITSI_PRIVATE_MODE, + FALLBACK_LANGUAGE, }; diff --git a/front/src/Phaser/Game/GameManager.ts b/front/src/Phaser/Game/GameManager.ts index e4f4ea1c..f023a7aa 100644 --- a/front/src/Phaser/Game/GameManager.ts +++ b/front/src/Phaser/Game/GameManager.ts @@ -1,14 +1,14 @@ -import { GameScene } from "./GameScene"; +import { get } from "svelte/store"; import { connectionManager } from "../../Connexion/ConnectionManager"; +import { localUserStore } from "../../Connexion/LocalUserStore"; import type { Room } from "../../Connexion/Room"; +import { helpCameraSettingsVisibleStore } from "../../Stores/HelpCameraSettingsStore"; +import { requestedCameraState, requestedMicrophoneState } from "../../Stores/MediaStore"; +import { menuIconVisiblilityStore } from "../../Stores/MenuStore"; +import { EnableCameraSceneName } from "../Login/EnableCameraScene"; import { LoginSceneName } from "../Login/LoginScene"; import { SelectCharacterSceneName } from "../Login/SelectCharacterScene"; -import { EnableCameraSceneName } from "../Login/EnableCameraScene"; -import { localUserStore } from "../../Connexion/LocalUserStore"; -import { get } from "svelte/store"; -import { requestedCameraState, requestedMicrophoneState } from "../../Stores/MediaStore"; -import { helpCameraSettingsVisibleStore } from "../../Stores/HelpCameraSettingsStore"; -import { menuIconVisiblilityStore } from "../../Stores/MenuStore"; +import { GameScene } from "./GameScene"; /** * This class should be responsible for any scene starting/stopping diff --git a/front/src/Phaser/Login/EntryScene.ts b/front/src/Phaser/Login/EntryScene.ts index 3fb2e6b5..d75ead03 100644 --- a/front/src/Phaser/Login/EntryScene.ts +++ b/front/src/Phaser/Login/EntryScene.ts @@ -4,6 +4,7 @@ import { ErrorScene, ErrorSceneName } from "../Reconnecting/ErrorScene"; import { WAError } from "../Reconnecting/WAError"; import { waScaleManager } from "../Services/WaScaleManager"; import { ReconnectingTextures } from "../Reconnecting/ReconnectingScene"; +import { translator } from "../../Translator/Translator"; export const EntrySceneName = "EntryScene"; @@ -12,6 +13,7 @@ export const EntrySceneName = "EntryScene"; * and to route to the next correct scene. */ export class EntryScene extends Scene { + constructor() { super({ key: EntrySceneName, @@ -24,41 +26,50 @@ export class EntryScene extends Scene { // Note: arcade.png from the Phaser 3 examples at: https://github.com/photonstorm/phaser3-examples/tree/master/public/assets/fonts/bitmap this.load.bitmapFont(ReconnectingTextures.mainFont, "resources/fonts/arcade.png", "resources/fonts/arcade.xml"); this.load.spritesheet("cat", "resources/characters/pipoya/Cat 01-1.png", { frameWidth: 32, frameHeight: 32 }); + translator.loadCurrentLanguageFile(this.load); } create() { - gameManager - .init(this.scene) - .then((nextSceneName) => { - // Let's rescale before starting the game - // We can do it at this stage. - waScaleManager.applyNewSize(); - this.scene.start(nextSceneName); + translator + .loadCurrentLanguageObject(this.cache) + .catch((e: unknown) => { + console.error("Error during language loading!", e); + throw e; }) - .catch((err) => { - if (err.response && err.response.status == 404) { - ErrorScene.showError( - new WAError( - "Access link incorrect", - "Could not find map. Please check your access link.", - "If you want more information, you may contact administrator or contact us at: hello@workadventu.re" - ), - this.scene - ); - } else if (err.response && err.response.status == 403) { - ErrorScene.showError( - new WAError( - "Connection rejected", - "You cannot join the World. Try again later" + - (err.response.data ? ". \n\r \n\r" + `${err.response.data}` : "") + - ".", - "If you want more information, you may contact administrator or contact us at: hello@workadventu.re" - ), - this.scene - ); - } else { - ErrorScene.showError(err, this.scene); - } + .finally(() => { + gameManager + .init(this.scene) + .then((nextSceneName) => { + // Let's rescale before starting the game + // We can do it at this stage. + waScaleManager.applyNewSize(); + this.scene.start(nextSceneName); + }) + .catch((err) => { + if (err.response && err.response.status == 404) { + ErrorScene.showError( + new WAError( + "Access link incorrect", + "Could not find map. Please check your access link.", + "If you want more information, you may contact administrator or contact us at: hello@workadventu.re" + ), + this.scene + ); + } else if (err.response && err.response.status == 403) { + ErrorScene.showError( + new WAError( + "Connection rejected", + "You cannot join the World. Try again later" + + (err.response.data ? ". \n\r \n\r" + `${err.response.data}` : "") + + ".", + "If you want more information, you may contact administrator or contact us at: hello@workadventu.re" + ), + this.scene + ); + } else { + ErrorScene.showError(err, this.scene); + } + }); }); } } diff --git a/front/src/Translator/TranslationCompiler.ts b/front/src/Translator/TranslationCompiler.ts new file mode 100644 index 00000000..6b43ba2c --- /dev/null +++ b/front/src/Translator/TranslationCompiler.ts @@ -0,0 +1,72 @@ +import fs from "fs"; + +const translationsBasePath = "./translations"; +const fallbackLanguage = process.env.FALLBACK_LANGUAGE || "en-US"; + +export type LanguageFound = { + id: string; + default: boolean; +}; + +const getAllLanguagesByFiles = (dirPath: string, languages: Array | undefined) => { + const files = fs.readdirSync(dirPath); + languages = languages || new Array(); + + files.forEach(function (file) { + if (fs.statSync(dirPath + "/" + file).isDirectory()) { + languages = getAllLanguagesByFiles(dirPath + "/" + file, languages); + } else { + const parts = file.split("."); + + if (parts.length !== 3 || parts[0] !== "index" || parts[2] !== "json") { + return; + } + + const rawData = fs.readFileSync(dirPath + "/" + file, "utf-8"); + const languageObject = JSON.parse(rawData); + + languages?.push({ + id: parts[1], + default: languageObject.default !== undefined && languageObject.default, + }); + } + }); + + return languages; +}; + +const getFallbackLanguageObject = (dirPath: string, languageObject: Object | undefined) => { + const files = fs.readdirSync(dirPath); + languageObject = languageObject || {}; + + files.forEach(function (file) { + if (fs.statSync(dirPath + "/" + file).isDirectory()) { + languageObject = getFallbackLanguageObject(dirPath + "/" + file, languageObject); + } else { + const parts = file.split("."); + + if (parts.length !== 3 || parts[1] !== fallbackLanguage || parts[2] !== "json") { + return; + } + + const rawData = fs.readFileSync(dirPath + "/" + file, "utf-8"); + languageObject = { ...languageObject, ...JSON.parse(rawData) }; + } + }); + + return languageObject; +}; + +const languagesToObject = () => { + const object: { [key: string]: boolean } = {}; + + languages.forEach((language) => { + object[language.id] = false; + }); + + return object; +}; + +export const languages = getAllLanguagesByFiles(translationsBasePath, undefined); +export const languagesObject = languagesToObject(); +export const fallbackLanguageObject = getFallbackLanguageObject(translationsBasePath, undefined); diff --git a/front/src/Translator/Translator.ts b/front/src/Translator/Translator.ts new file mode 100644 index 00000000..7e02ac53 --- /dev/null +++ b/front/src/Translator/Translator.ts @@ -0,0 +1,170 @@ +import { FALLBACK_LANGUAGE } from "../Enum/EnvironmentVariable"; +import { getCookie } from "../Utils/Cookies"; + +export type Language = { + language: string; + country: string; +}; + +type LanguageObject = { + [key: string]: string | LanguageObject; +}; + +class Translator { + public readonly fallbackLanguage: Language = this.getLanguageByString(FALLBACK_LANGUAGE) || { + language: "en", + country: "US", + }; + + private readonly fallbackLanguageObject: LanguageObject = FALLBACK_LANGUAGE_OBJECT as LanguageObject; + + private currentLanguage: Language; + private currentLanguageObject: LanguageObject; + + public constructor() { + this.currentLanguage = this.fallbackLanguage; + this.currentLanguageObject = this.fallbackLanguageObject; + + this.defineCurrentLanguage(); + } + + public getLanguageByString(languageString: string): Language | undefined { + const parts = languageString.split("-"); + if (parts.length !== 2 || parts[0].length !== 2 || parts[1].length !== 2) { + console.error(`Language string "${languageString}" do not respect RFC 5646 with language and country code`); + return undefined; + } + + return { + language: parts[0].toLowerCase(), + country: parts[1].toUpperCase(), + }; + } + + public getStringByLanguage(language: Language): string | undefined { + return `${language.language}-${language.country}`; + } + + public loadCurrentLanguageFile(pluginLoader: Phaser.Loader.LoaderPlugin) { + const languageString = this.getStringByLanguage(this.currentLanguage); + pluginLoader.json({ + key: `language-${languageString}`, + url: `resources/translations/${languageString}.json`, + }); + } + + public loadCurrentLanguageObject(cacheManager: Phaser.Cache.CacheManager): Promise { + return new Promise((resolve, reject) => { + const languageObject: Object = cacheManager.json.get( + `language-${this.getStringByLanguage(this.currentLanguage)}` + ); + + if (!languageObject) { + return reject(); + } + + this.currentLanguageObject = languageObject as LanguageObject; + return resolve(); + }); + } + + public getLanguageWithoutCountry(languageString: string): Language | undefined { + if (languageString.length !== 2) { + return undefined; + } + + let languageFound = undefined; + + const languages: { [key: string]: boolean } = LANGUAGES as { [key: string]: boolean }; + + for (const language in languages) { + if (language.startsWith(languageString) && languages[language]) { + languageFound = this.getLanguageByString(language); + break; + } + } + + return languageFound; + } + + private defineCurrentLanguage() { + const navigatorLanguage: string | undefined = navigator.language; + const cookieLanguage = getCookie("language"); + let currentLanguage = undefined; + + if (cookieLanguage && typeof cookieLanguage === "string") { + const cookieLanguageObject = this.getLanguageByString(cookieLanguage); + if (cookieLanguageObject) { + currentLanguage = cookieLanguageObject; + } + } + + if (!currentLanguage && navigatorLanguage) { + const navigatorLanguageObject = + navigator.language.length === 2 + ? this.getLanguageWithoutCountry(navigatorLanguage) + : this.getLanguageByString(navigatorLanguage); + if (navigatorLanguageObject) { + currentLanguage = navigatorLanguageObject; + } + } + + if (!currentLanguage || currentLanguage === this.fallbackLanguage) { + return; + } + + this.currentLanguage = currentLanguage; + } + + private getObjectValueByPath(path: string, object: LanguageObject): string | undefined { + const paths = path.split("."); + let currentValue: LanguageObject | string = object; + + for (const path of paths) { + if (typeof currentValue === "string" || currentValue[path] === undefined) { + return undefined; + } + + currentValue = currentValue[path]; + } + + if (typeof currentValue !== "string") { + return undefined; + } + + return currentValue; + } + + private formatStringWithParams(string: string, params: { [key: string]: string | number }): string { + let formattedString = string; + + for (const param in params) { + const regex = `/{{\\s*\\${param}\\s*}}/g`; + formattedString = formattedString.replace(new RegExp(regex), params[param].toString()); + } + + return formattedString; + } + + public _(key: string, params?: { [key: string]: string | number }): string { + const currentLanguageValue = this.getObjectValueByPath(key, this.currentLanguageObject); + + if (currentLanguageValue) { + return params ? this.formatStringWithParams(currentLanguageValue, params) : currentLanguageValue; + } + + console.warn(`"${key}" key cannot be found in ${this.getStringByLanguage(this.currentLanguage)} language`); + + const fallbackLanguageValue = this.getObjectValueByPath(key, this.fallbackLanguageObject); + + if (fallbackLanguageValue) { + return params ? this.formatStringWithParams(fallbackLanguageValue, params) : fallbackLanguageValue; + } + + console.warn(`"${key}" key cannot be found in ${this.getStringByLanguage(this.fallbackLanguage)} fallback language`); + + return key; + } +} + +export const translator = new Translator(); diff --git a/front/src/Utils/Cookies.ts b/front/src/Utils/Cookies.ts new file mode 100644 index 00000000..3ca418c2 --- /dev/null +++ b/front/src/Utils/Cookies.ts @@ -0,0 +1,20 @@ +export const setCookie = (name: string, value: unknown, days: number) => { + let expires = ""; + if (days) { + const date = new Date(); + date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); + expires = "; expires=" + date.toUTCString(); + } + document.cookie = name + "=" + (value || "") + expires + "; path=/"; +}; + +export const getCookie = (name: string): unknown | undefined => { + const nameEquals = name + "="; + const ca = document.cookie.split(";"); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) == " ") c = c.substring(1, c.length); + if (c.indexOf(nameEquals) == 0) return c.substring(nameEquals.length, c.length); + } + return undefined; +}; diff --git a/front/src/define-plugin.d.ts b/front/src/define-plugin.d.ts new file mode 100644 index 00000000..679cf2e0 --- /dev/null +++ b/front/src/define-plugin.d.ts @@ -0,0 +1,2 @@ +declare const FALLBACK_LANGUAGE_OBJECT: Object; +declare const LANGUAGES: Object; diff --git a/front/translations/en-US/index.en-US.json b/front/translations/en-US/index.en-US.json new file mode 100644 index 00000000..8de1fc09 --- /dev/null +++ b/front/translations/en-US/index.en-US.json @@ -0,0 +1,5 @@ +{ + "language": "English", + "country": "United States", + "default": true +} diff --git a/front/translations/en-US/test.en-US.json b/front/translations/en-US/test.en-US.json new file mode 100644 index 00000000..5bc56b94 --- /dev/null +++ b/front/translations/en-US/test.en-US.json @@ -0,0 +1,5 @@ +{ + "test": { + "nolway": "Too mutch cofee" + } +} diff --git a/front/translations/fr-FR/index.fr-FR.json b/front/translations/fr-FR/index.fr-FR.json new file mode 100644 index 00000000..b741e3bd --- /dev/null +++ b/front/translations/fr-FR/index.fr-FR.json @@ -0,0 +1,5 @@ +{ + "language": "Français", + "country": "France", + "default": true +} diff --git a/front/translations/fr-FR/test.fr-FR.json b/front/translations/fr-FR/test.fr-FR.json new file mode 100644 index 00000000..ab428962 --- /dev/null +++ b/front/translations/fr-FR/test.fr-FR.json @@ -0,0 +1,5 @@ +{ + "test": { + "nolway": "Trop de café" + } +} diff --git a/front/webpack.config.ts b/front/webpack.config.ts index 77ad92bd..b407adb9 100644 --- a/front/webpack.config.ts +++ b/front/webpack.config.ts @@ -1,12 +1,16 @@ -import type { Configuration } from "webpack"; -import type WebpackDevServer from "webpack-dev-server"; -import path from "path"; -import webpack from "webpack"; +import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin"; import HtmlWebpackPlugin from "html-webpack-plugin"; import MiniCssExtractPlugin from "mini-css-extract-plugin"; -import sveltePreprocess from "svelte-preprocess"; -import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin"; import NodePolyfillPlugin from "node-polyfill-webpack-plugin"; +import path from "path"; +import sveltePreprocess from "svelte-preprocess"; +import type { Configuration } from "webpack"; +import webpack from "webpack"; +import type WebpackDevServer from "webpack-dev-server"; +import type { LanguageFound } from "./src/Translator/TranslationCompiler"; +import { fallbackLanguageObject, languages, languagesObject } from "./src/Translator/TranslationCompiler"; + +const MergeJsonWebpackPlugin = require("merge-jsons-webpack-plugin"); const mode = process.env.NODE_ENV ?? "development"; const buildNpmTypingsForApi = !!process.env.BUILD_TYPINGS; @@ -141,6 +145,11 @@ module.exports = { filename: "fonts/[name][ext]", }, }, + { + test: /\.json$/, + exclude: /node_modules/, + type: "asset", + }, ], }, resolve: { @@ -210,6 +219,24 @@ module.exports = { NODE_ENV: mode, DISABLE_ANONYMOUS: false, OPID_LOGIN_SCREEN_PROVIDER: null, + FALLBACK_LANGUAGE: null, + }), + new webpack.DefinePlugin({ + FALLBACK_LANGUAGE_OBJECT: JSON.stringify(fallbackLanguageObject), + LANGUAGES: JSON.stringify(languagesObject), + }), + new MergeJsonWebpackPlugin({ + output: { + groupBy: languages.map((language: LanguageFound) => { + return { + pattern: `./translations/**/*.${language.id}.json`, + fileName: `./resources/translations/${language.id}.json` + }; + }) + }, + globOptions: { + nosort: true, + }, }), ], } as Configuration & WebpackDevServer.Configuration; diff --git a/front/yarn.lock b/front/yarn.lock index d2ac31b3..d6768844 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -2776,6 +2776,18 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== +glob@7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8" + integrity sha1-gFIR3wT6rxxjo2ADBs31reULLsg= + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.2" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@^7.0.3, glob@^7.1.3, glob@^7.1.6: version "7.1.7" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" @@ -3860,6 +3872,13 @@ merge-descriptors@1.0.1: resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= +merge-jsons-webpack-plugin@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/merge-jsons-webpack-plugin/-/merge-jsons-webpack-plugin-2.0.1.tgz#f2975ce0f734171331d42eee62d63329031800b4" + integrity sha512-8GP8rpOX3HSFsm7Gx+b3OAQR7yhgeAQvMqcZOJ+/cQIrqdak1c42a2T2vyeee8pzGPBf7pMLumthPh4CHgv2BA== + dependencies: + glob "7.1.1" + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -3961,7 +3980,7 @@ minimalistic-crypto-utils@^1.0.1: resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= -minimatch@^3.0.4: +minimatch@^3.0.2, minimatch@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==