UNPKG

@transifex/native

Version:
597 lines (545 loc) 16.9 kB
/* globals __VERSION__, __PLATFORM__ */ import fetch from 'cross-fetch'; import MemoryCache from './cache/MemoryCache'; import SourceErrorPolicy from './policies/SourceErrorPolicy'; import SourceStringPolicy from './policies/SourceStringPolicy'; import MessageFormatRenderer from './renderers/MessageFormatRenderer'; import { generateKey, isString, escape, generateHashedKey, sleep, } from './utils'; import { sendEvent, FETCHING_TRANSLATIONS, TRANSLATIONS_FETCHED, TRANSLATIONS_FETCH_FAILED, FETCHING_LOCALES, LOCALES_FETCHED, LOCALES_FETCH_FAILED, LOCALE_CHANGED, } from './events'; import { isPluralized } from './plurals'; /** * Native instance, combines functionality from * NativeCore and LangState classes. * * @export * @class TxNative */ export default class TxNative { constructor() { this.cdsHost = 'https://cds.svc.transifex.net'; this.token = ''; this.secret = ''; this.filterTags = ''; this.filterStatus = ''; this.fetchTimeout = 0; this.fetchInterval = 250; this.cache = new MemoryCache(); this.missingPolicy = new SourceStringPolicy(); this.errorPolicy = new SourceErrorPolicy(); this.stringRenderer = new MessageFormatRenderer(); this.currentLocale = ''; this.locales = []; this.languages = []; this.childInstances = []; } /** * Initialize Native instance * * @param {Object} params * @param {String} params.cdsHost * @param {String} params.filterTags * @param {String} params.filterStatus * @param {String} params.token * @param {String} params.secret * @param {Number} params.fetchTimeout * @param {Number} params.fetchInterval * @param {Function} params.cache * @param {Function} params.missingPolicy * @param {Function} params.errorPolicy * @param {Function} params.stringRenderer */ init(params) { const that = this; [ 'cdsHost', 'token', 'secret', 'cache', 'filterTags', 'filterStatus', 'fetchTimeout', 'fetchInterval', 'missingPolicy', 'errorPolicy', 'stringRenderer', 'currentLocale', ].forEach((value) => { if (params[value] !== undefined) { that[value] = params[value]; } }); this.fetchedTags = {}; // {langCode: [tag1, tag2, ...], ...} } /** * Translate string in current language * * @param {String} sourceString * @param {Object} params * @param {String} params._context - Source context, affects key generation * @param {String} params._comment - Developer comment * @param {Number} params._charlimit - Character limit * @param {String} params._tags - Comma separated list of tags * @param {String} params._key - Custom key * @param {Boolean} params._escapeVars - If true escape ICU variables * @returns {String} */ translate(sourceString, params) { return this.translateLocale(this.currentLocale, sourceString, params); } /** * Translate string to specific locale * * @param {String} locale * @param {String} sourceString * @param {Object} params - See {@link translate} * @returns {String} */ translateLocale(locale, sourceString, params) { try { // get translation from source based key (2.x.x) let translation = this.cache.get( generateKey(sourceString, params), locale, ); // fall back to hash based key (1.x.x) if (!translation) { translation = this.cache.get( generateHashedKey(sourceString, params), locale, ); } if (translation && translation.startsWith('{???') && isPluralized(sourceString) ) { const variableName = sourceString .substring(1, sourceString.indexOf(',')) .trim(); translation = `{${variableName}${translation.substring(4)}`; } let isMissing = false; if (!translation) { isMissing = true; translation = sourceString; } if (params && params._escapeVars) { const safeParams = {}; Object.keys(params).forEach((property) => { const value = params[property]; safeParams[property] = isString(value) ? escape(value) : value; }); translation = this.stringRenderer.render(translation, locale, safeParams); } else { translation = this.stringRenderer.render(translation, locale, params); } if (isMissing && locale) { translation = this.missingPolicy.handle(translation, locale, params); } if (!isString(translation)) translation = `${translation}`; return translation; } catch (err) { return this.errorPolicy.handle( err, `${sourceString}`, locale, params, ); } } /** * Fetch locale translations from CDS * * @param {String} localeCode * @param {Object} params * @param {Boolean} params.refresh - Force re-fetching of content * @returns {Promise} */ async fetchTranslations(localeCode, params = {}) { const filterTags = params.filterTags || this.filterTags; if (!params.refresh && !this.cache.isStale(localeCode) && ( (!filterTags && this.cache.hasTranslations(localeCode)) || (filterTags && (this.fetchedTags[localeCode] || []).indexOf(filterTags) !== -1) )) { return; } if (filterTags) { if (!(localeCode in this.fetchedTags)) { this.fetchedTags[localeCode] = []; } if (this.fetchedTags[localeCode].indexOf(filterTags) === -1) { this.fetchedTags[localeCode].push(filterTags); } } const handleError = (err) => { sendEvent(TRANSLATIONS_FETCH_FAILED, { localeCode, filterTags }, this); return err; }; // contact CDS try { sendEvent(FETCHING_TRANSLATIONS, { localeCode, filterTags }, this); let response; let lastResponseStatus = 202; const tsNow = Date.now(); while (lastResponseStatus === 202) { let url = `${this.cdsHost}/content/${localeCode}`; const getOptions = []; if (filterTags) { getOptions.push(`filter[tags]=${filterTags}`); } if (this.filterStatus) { getOptions.push(`filter[status]=${this.filterStatus}`); } if (getOptions.length) { url = `${url}?${getOptions.join('&')}`; } response = await fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${this.token}`, 'Accept-version': 'v2', 'X-NATIVE-SDK': `txjs/${__PLATFORM__}/${__VERSION__}`, }, signal: this.fetchTimeout > 0 ? AbortSignal.timeout(this.fetchTimeout) : undefined, }); if (!response.ok) { throw (await this._fetchError(response)); } lastResponseStatus = response.status; if (this.fetchTimeout > 0 && (Date.now() - tsNow) >= this.fetchTimeout) { throw handleError(new Error('Fetch translations timeout')); } if (lastResponseStatus === 202 && this.fetchInterval > 0) { await sleep(this.fetchInterval); } } const data = await response.json(); if (data && data.data) { const hashmap = {}; Object.keys(data.data).forEach((key) => { if (data.data[key].string) { hashmap[key] = data.data[key].string; } }); this.cache.update(localeCode, hashmap); sendEvent(TRANSLATIONS_FETCHED, { localeCode, filterTags }, this); } else { throw handleError(new Error('Could not fetch translations')); } } catch (err) { throw handleError(err); } } /** * Invalidate CDS cache * * @param {Object} params * @param {Boolean} params.purge * @returns {Object} Data * @returns {Number} Data.count * @returns {Number} Data.status * @returns {Number} Data.token */ async invalidateCDS(params = {}) { if (!this.token) throw new Error('token is not defined'); if (!this.secret) throw new Error('secret is not defined'); const action = params.purge ? 'purge' : 'invalidate'; const response = await fetch(`${this.cdsHost}/${action}`, { method: 'POST', headers: { Authorization: `Bearer ${this.token}:${this.secret}`, 'Accept-version': 'v2', 'Content-Type': 'application/json;charset=utf-8', 'X-NATIVE-SDK': `txjs/${__PLATFORM__}/${__VERSION__}`, }, }); if (!response.ok) { throw (await this._fetchError(response)); } const data = await response.json(); return data; } /** * Push source content to CDS. * * Payload is in the following format: * { * <key>: { * string: <string>, * meta: { * context: <string> * developer_comment: <string>, * character_limit: <number>, * tags: <array>, * occurrences: <array>, * } * }, * <key>: { .. } * } * * @param {Object} payload * @param {Object} params * @param {Boolean} params.purge * @param {Boolean} params.overrideTags * @param {Boolean} params.overrideOccurrences * @param {Boolean} params.noWait - do not wait for upload results * @returns {Object} Data * @returns {String} Data.jobUrl * @returns {Number} Data.created * @returns {Number} Data.updated * @returns {Number} Data.skipped * @returns {Number} Data.deleted * @returns {Number} Data.failed * @returns {String[]} Data.errors * @returns {String} Data.status */ async pushSource(payload, params = {}) { if (!this.token) throw new Error('token is not defined'); if (!this.secret) throw new Error('secret is not defined'); const headers = { Authorization: `Bearer ${this.token}:${this.secret}`, 'Accept-version': 'v2', 'Content-Type': 'application/json;charset=utf-8', 'X-NATIVE-SDK': `txjs/${__PLATFORM__}/${__VERSION__}`, }; const response = await fetch(`${this.cdsHost}/content`, { method: 'POST', headers, body: JSON.stringify({ data: payload, meta: { purge: !!params.purge, override_tags: !!params.overrideTags, override_occurrences: !!params.overrideOccurrences, }, }), }); if (!response.ok) { throw (await this._fetchError(response)); } const postResData = await response.json(); const jobUrl = `${this.cdsHost}${postResData.data.links.job}`; if (params.noWait) { return { jobUrl, }; } let pollStatus = { status: '', }; do { await sleep(1500); const pollRes = await fetch(jobUrl, { method: 'GET', headers, }); if (!pollRes.ok) { throw (await this._fetchError(pollRes)); } const pollResData = await pollRes.json(); const { data } = pollResData; pollStatus = { ...(data.details || {}), errors: data.errors || [], status: data.status, }; } while (pollStatus.status === 'pending' || pollStatus.status === 'processing'); return { jobUrl, ...pollStatus, }; } /** * Get remote project locales from CDS * * @param {Object} params * @param {Boolean} params.refresh - Force re-fetching of content * @returns {Promise<String[]>} */ async getLocales(params = {}) { const refresh = !!params.refresh; if (!refresh && this.locales.length > 0) { return [...this.locales]; } if (!this.token) return []; const handleError = (err) => { sendEvent(LOCALES_FETCH_FAILED, null, this); return err; }; // contact CDS try { sendEvent(FETCHING_LOCALES, null, this); let response; let lastResponseStatus = 202; const tsNow = Date.now(); while (lastResponseStatus === 202) { response = await fetch(`${this.cdsHost}/languages`, { method: 'GET', headers: { Authorization: `Bearer ${this.token}`, 'Accept-version': 'v2', 'X-NATIVE-SDK': `txjs/${__PLATFORM__}/${__VERSION__}`, }, signal: this.fetchTimeout > 0 ? AbortSignal.timeout(this.fetchTimeout) : undefined, }); if (!response.ok) { throw (await this._fetchError(response)); } lastResponseStatus = response.status; if (this.fetchTimeout > 0 && (Date.now() - tsNow) >= this.fetchTimeout) { throw handleError(new Error('Get locales timeout')); } if (lastResponseStatus === 202 && this.fetchInterval > 0) { await sleep(this.fetchInterval); } } const data = await response.json(); if (data && data.data) { this.languages = data.data; this.locales = this.languages.map((entry) => entry.code); sendEvent(LOCALES_FETCHED, null, this); } else { throw handleError(new Error('Could not fetch languages')); } } catch (err) { throw handleError(err); } return [...this.locales]; } /** * Get currently selected locale * * @returns {String} */ getCurrentLocale() { return this.currentLocale; } /** * Check if a locale is the currently selected one * * @param {String} localeCode * @returns {Boolean} */ isCurrent(localeCode) { return localeCode === this.currentLocale; } /** * Set current locale for translating content * * @param {String} localeCode * @returns {Promise} */ async setCurrentLocale(localeCode) { if (this.isCurrent(localeCode)) { await this._syncInstances(this.childInstances); return; } if (!localeCode) { // update controller this.currentLocale = ''; await this._syncInstances(this.childInstances); sendEvent(LOCALE_CHANGED, this.currentLocale, this); return; } // Fetch translations for controller instance await this.fetchTranslations(localeCode); this.currentLocale = localeCode; // Update children await this._syncInstances(this.childInstances); // Trigger controller change sendEvent(LOCALE_CHANGED, localeCode, this); } /** * Set detailed list of supported languages, useful for creating * language pickers * * @param {Object} params * @param {Boolean} params.refresh - Force re-fetching of content * @returns {Promise<Language[]>} * @returns {String} Language.name * @returns {String} Language.code * @returns {String} Language.localized_name * @returns {Boolean} Language.rtl */ async getLanguages(params = {}) { await this.getLocales(params); return [...this.languages]; } /** * Connect a child instance with this instance as controller. * When the language is changing on this instance, all child * instances will be updated as well. * * @param {*} instance * @returns {Promise} */ async controllerOf(instance) { if (instance === this) { throw new Error('Cannot add self as instance'); } if (instance.childInstances.indexOf(this) !== -1) { throw new Error('Cycle reference error, instance is controller of this'); } this.childInstances.push(instance); await this._syncInstances([instance]); return instance; } /** * Private function to sync controller with * child instance. * * @param {Array} instances * @memberof TxNative */ async _syncInstances(instances) { // update instance language const localeCode = this.getCurrentLocale(); // update children instances if (localeCode) { for (let i = 0; i < instances.length; i++) { // do not fetch language if not needed if (instances[i].getCurrentLocale() !== localeCode) { // Fetch translations for additional instance without blocking // anything else in case of missing language try { await instances[i].fetchTranslations(localeCode); } catch (e) { // no-op } } } } // Reloop through the instances to avoid content flash instances.forEach((instance) => { if (instance.getCurrentLocale() !== localeCode) { // eslint-disable-next-line no-param-reassign instance.currentLocale = localeCode; sendEvent(LOCALE_CHANGED, localeCode, instance); } }); } /** * Return a new fetch error * * @param {*} response * @memberof TxNative */ // eslint-disable-next-line class-methods-use-this async _fetchError(response) { try { const text = await response.text(); return new Error(`HTTP ${response.status}: ${text}`); } catch (err) { return new Error(`HTTP error ${response.status}`); } } }