Source

frontend/controllers/i18n_controller.js

/* This file is part of Ezra Bible App.

   Copyright (C) 2019 - 2023 Ezra Bible App Development Team <contact@ezrabibleapp.net>

   Ezra Bible App is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation, either version 2 of the License, or
   (at your option) any later version.

   Ezra Bible App is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with Ezra Bible App. See the file LICENSE.
   If not, see <http://www.gnu.org/licenses/>. */

const locales = require('../../../locales/locales.json');
const eventController = require('./event_controller.js');
const cacheController = require('./cache_controller.js');
const { waitUntilIdle } = require('../helpers/ezra_helper.js');

/**
 * This controller initializes the app locale at startup and updates it on demand when changing the locale
 * @module i18nController
 * @category Controller
 */

const SETTINGS_KEY = 'appLocale';
const jqueryI18next = require('jquery-i18next');

const i18nextOptions = {
  debug: false,
  interpolation: {
    escapeValue: false,
    /** 
     * adds context and format to the interpolation
     * @example translation.json:
     * ...
     * term: "переклади Біблії",
     * term_locative: "перекладах Біблії",
     * ...
     * message: Інформація о {{term, locative}},
     * ...
     */
    format(value, format, lng) { 
      if (value instanceof Date) {
        let dateStr = '';
        try {
          dateStr = value.toLocaleDateString(lng);
        } catch(err) {
          console.log('Unable to localize date:', err);
        }
        return dateStr;
      }
      
      var context = format;
      if (format) {
        const parts = format.split(',');
        if (parts.length > 1) {
          context = parts.shift().trim();
          format = parts.join(',').trim();
        } else if (format === 'capitalize' || format === 'title-case') {
          context = undefined;
        }
      }

      if (value == 'BIBLE') {
        value = "module-assistant.module-type-bible";
      } else if (value == 'DICT') {
        value = "module-assistant.module-type-dict";
      } else if (value == 'COMMENTARY') {
        value = "module-assistant.module-type-commentary";
      }

      value = i18n.t(value, {context});

      if (format === 'capitalize') {
        value = value.replace(/^\S/, (c) => c.toLocaleUpperCase());
      } else if (format === 'title-case') {
        value = value.replace(/\S*/g, (w) => (w.replace(/^\S/, (c) => c.toLocaleUpperCase())));
      }

      return value;
    }
  },
  saveMissing: false,
  fallbackLng: locales.fallback,
  whitelist: locales.available,
  react: {
    wait: false
  }
};

var systemLocale;

module.exports.initI18N = async function() {
  window.i18n = require('i18next');
  const I18nIpcBackend = require('../ipc/i18n_ipc_backend.js');

  let LanguageDetector = require('../platform/i18next_language_detector.js');

  await i18n
    .use(LanguageDetector)
    .use(I18nIpcBackend)
    .init(i18nextOptions);

  systemLocale = this.getLocale();

  jqueryI18next.init(i18n, $, {
    tName: 't', // --> appends $.t = i18next.t
    i18nName: 'i18n', // --> appends $.i18n = i18next
    handleName: 'localize', // --> appends $(selector).localize(opts);
    selectorAttr: 'i18n', // selector for translating elements
    targetAttr: 'i18n-target', // data-() attribute to grab target element to translate (if different than itself)
    optionsAttr: 'i18n-options', // data-() attribute that contains options, will load/set if useOptionsAttr = true
    useOptionsAttr: true, // see optionsAttr
    parseDefaultValueFromContent: true // parses default values from content ele.val or ele.text
  });

  if (platformHelper.isElectron()) {
    await this.initLocale();
  }
};

module.exports.initLocale = async function() {
  if (await ipcSettings.has(SETTINGS_KEY)) {
    let locale = await ipcSettings.get(SETTINGS_KEY, locales.fallback);

    if (typeof(locale) == 'string') {
      console.log(`Using locale ${locale}`);
      await i18n.changeLanguage(locale);
    } else {
      console.log(`Invalid locale of type ${typeof(locale)}`);
    }
  }

  // We need to save some locale strings separately, so that they are accessible at startup before i18next is available
  preserveStringsForStartup();

  if (platformHelper.isTest()) { // Use English for test mode
    await i18n.changeLanguage('en');
  }

  window.reference_separator = i18n.t('general.chapter-verse-separator');

  if (platformHelper.isMac()) {
    await this.localizeMenu();
  }
};

function preserveStringsForStartup() {
  if (!window.localStorage) {
    return;
  } 
  
  const localeStorage = window.localStorage;
  const keys = ["general.loading", "cordova.starting-app", "cordova.init-i18n", "cordova.init-sword", "cordova.init-database", "cordova.init-user-interface"];
  for(const key of keys) {
    let translation = i18n.t(key);
    localeStorage.setItem(key, translation);
  }
}

module.exports.getStringForStartup = function(key, fallbackText) {
  const localizedText = window.localStorage && window.localStorage.getItem(key);
  return localizedText || fallbackText;
};

module.exports.changeLocale = async function(newLocale, saveSettings=true) {

  if (platformHelper.isCordova()) {
    uiHelper.showTextLoadingIndicator();
    await waitUntilIdle();
  }

  await i18n.changeLanguage(newLocale);
  preserveStringsForStartup();

  if (saveSettings) {
    await ipcSettings.set(SETTINGS_KEY, newLocale);
  }

  await cacheController.saveLastLocale();

  $(document).localize();

  if (platformHelper.isCordova()) {
    await waitUntilIdle();
    uiHelper.hideTextLoadingIndicator();
  }

  window.reference_separator = i18n.t('general.chapter-verse-separator');
  await eventController.publishAsync('on-locale-changed', newLocale);

  if (platformHelper.isMac()) {
    await this.localizeMenu();
  }
};

module.exports.localizeMenu = async function() {
  const { ipcRenderer } = require('electron');

  let menuLabels = {
    'quit-app': i18n.t('application-menu.quit-app')
  };

  await ipcRenderer.invoke('localizeMenu', menuLabels);
};

module.exports.detectLocale = async function() {
  await this.changeLocale(systemLocale || locales.fallback, false);
  await ipcSettings.delete(SETTINGS_KEY);
};

/** returns current app locale (2-letter language code or 2-letter language - 2-letter region) */
module.exports.getLocale = function() {
  var locale = i18n.language; // FIXME: case when the language comes with the region code (i.e "en-US") we want only the language code ("en") from available locales
  //console.log('getting 2 code locale:', locale);
  return locale; 
};

module.exports.getSystemLocale = () => systemLocale;

/** returns detected OS locale */
module.exports.getSystemLocale = () => systemLocale;

/** returns code list of all available locales for the app */
module.exports.getAvailableLocales = function() {
  return locales.available.sort();
};