UNPKG

tc-source-content-updater

Version:

Module that updates source content for the desktop application translationCore.

774 lines (683 loc) 28.1 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.getLocalResourceList = exports.getReleaseMetaData = exports.getLatestRelease = exports.downloadManifestData = exports.searchCatalogNext = exports.getCatalog = exports.getOldCatalogReleases = exports.doMultipartQuery = exports.doMultipartQueryPage = exports.makeJsonRequestDetailed = exports.DCS_BASE_URL = exports.SEARCH_LIMIT = exports.OWNER_SEPARATOR = exports.EMPTY_TIME = exports.TRANSLATION_HELPS = exports.DEFAULT_OWNER = exports.UNFOLDING_WORD = exports.CN_CATALOG = exports.DOOR43_CATALOG = undefined; var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; /** * does http request and returns the response data parsed from JSON * @param {string} url * @param {number} retries * @return {Promise<{Object}>} */ let makeJsonRequestDetailed = exports.makeJsonRequestDetailed = (() => { var _ref = _asyncToGenerator(function* (url, retries = 5) { let result_; for (let i = 1; i <= retries; i++) { result_ = null; try { result_ = yield new Promise(function (resolve, reject) { request(url, function (error, response, body) { if (error) reject(error);else if (response.statusCode === 200) { let result = body; try { result = JSON.parse(body); } catch (e) { reject(e); } resolve({ result, response, body }); } else { reject(`fetch error ${response.statusCode}`); } }); }); } catch (e) { if (i >= retries) { console.warn(`makeJsonRequestDetailed(${url}) - error getting data`, e); throw e; } result_ = null; console.log(`makeJsonRequestDetailed(${url}) - retry ${i + 1} getting data, last error`, e); yield (0, _utils.delay)(500); } if (result_) { break; } } return result_; }); return function makeJsonRequestDetailed(_x) { return _ref.apply(this, arguments); }; })(); /** * does specific page query and returns the response data parsed from JSON * @param {string} url * @param {number} page * @param {number} retries * @return {Promise<{Object}>} */ let doMultipartQueryPage = exports.doMultipartQueryPage = (() => { var _ref2 = _asyncToGenerator(function* (url, page = 1, retries = 5) { const url_ = `${url}&page=${page}`; const { result, response } = yield makeJsonRequestDetailed(url_, retries); const pos = response && response.rawHeaders && response.rawHeaders.indexOf('X-Total-Count'); const totalCount = pos >= 0 ? parseInt(response.rawHeaders[pos + 1]) : 0; const items = result && result.data || null; return { items, totalCount }; }); return function doMultipartQueryPage(_x2) { return _ref2.apply(this, arguments); }; })(); /** * does multipart query and returns the response data parsed from JSON. Continues to read pages until all results are returned. * @param {string} url * @param {number} retries * @return {Promise<{Object}>} */ let doMultipartQuery = exports.doMultipartQuery = (() => { var _ref3 = _asyncToGenerator(function* (url, retries = 5) { let page = 1; let data = []; const { items, totalCount } = yield doMultipartQueryPage(url, page, retries = 5); let lastItems = items; let totalCount_ = totalCount; data = data.concat(items); while (lastItems && data.length < totalCount_) { const { items, totalCount } = yield doMultipartQueryPage(url, ++page, retries = 5); lastItems = items; totalCount_ = totalCount; if (items && items.length) { data = data.concat(items); } } return data; }); return function doMultipartQuery(_x3) { return _ref3.apply(this, arguments); }; })(); /** * searching subjects * @param {array} subjects * @param {string} owner * @param {number} retries * @return {Promise<*>} */ let searchSubjects = (() => { var _ref4 = _asyncToGenerator(function* (subjects, owner, retries = 3) { const subjectParam = encodeURI(subjects.join(',')); let fetchUrl = `${DCS_BASE_URL}/api/v1/catalog/search?metadataType=rc&partialMatch=0&subject=${subjectParam}`; if (owner) { fetchUrl += fetchUrl + `&owner=${owner}`; } const result = yield doMultipartQuery(fetchUrl, retries); return result; }); return function searchSubjects(_x4, _x5) { return _ref4.apply(this, arguments); }; })(); /** * get published catalog using catalog next v3 * @return {Promise<*[]>} */ let getOldCatalogReleases = exports.getOldCatalogReleases = (() => { var _ref5 = _asyncToGenerator(function* () { const released = []; const owner = null; // get all owners const subjectList = ['Bible', 'Aligned Bible', 'Greek New Testament', 'Hebrew Old Testament', 'Translation Words', 'TSV Translation Notes', 'Translation Academy']; // const subjectList = ['Bible', 'Testament', 'Translation Words', 'TSV Translation Notes', 'Translation Academy']; try { const result = yield searchSubjects(subjectList, owner, 5); let repos = 0; const languages = result && result.languages || []; for (const language of languages) { const languageId = language.identifier; const resources = language.resources || []; for (const resource of resources) { resource.languageId = languageId; resource.resourceId = resource.identifier; resource.foundInCatalog = 'OLD'; resource.full_name = resource.full_name || `${resource.owner}/${resource.repo}`; resource.checking_level = resource.checking && resource.checking.checking_level; const formats = resource.formats; if (formats && formats.length > 1) { console.log('too many'); } const firstFormat = formats && formats[0]; resource.downloadUrl = firstFormat && firstFormat.url; released.push(resource); repos++; } } console.log(`has ${repos} items`); console.log(`released catalog has ${released.length} items`); } catch (e) { console.error('getOldCatalogReleases() - error getting catalog', e); return []; } return released; }); return function getOldCatalogReleases() { return _ref5.apply(this, arguments); }; })(); /** * find matching resource in resourceList * @param {array} resourceList * @param {object} resource * @return {*} */ /** * get published catalog - combines catalog next releases with old catalog * @param {object} config * @param {string|null} config.stage - stage for search, default is prod * @return {Promise<*[]>} */ let getCatalog = exports.getCatalog = (() => { var _ref6 = _asyncToGenerator(function* (config = {}) { let searchParams = { subject: _index.SUBJECT.ALL_TC_RESOURCES, stage: _index.STAGE.LATEST, owner: DOOR43_CATALOG, DCS_BASE_URL: config.DCS_BASE_URL, partialMatch: '0' }; const fetchDoor43Catalog = !config.ignoreDoor43Catalog; const catalogReleases = fetchDoor43Catalog ? yield searchCatalogNext(searchParams) : []; console.log(`getCatalog - found ${catalogReleases.length} items in old Door43-Catalog`); searchParams = { subject: _index.SUBJECT.ALL_TC_RESOURCES, stage: config.stage || _index.STAGE.PROD, DCS_BASE_URL: config.DCS_BASE_URL, partialMatch: '0', ignoreDoor43Catalog: config.ignoreDoor43Catalog, topic: config.topic }; const newCatalogReleases = yield searchCatalogNext(searchParams); console.log(`getCatalog - found ${newCatalogReleases.length} items in catalog next`); let catalogReleases_ = filterOutMasterBranch(catalogReleases, ['obs', 'obs-tn'], newCatalogReleases); console.log(`getCatalog - found ${catalogReleases_.length} items in old Door43-Catalog after filter`); const newCatalogReleases_ = filterOutMasterBranch(newCatalogReleases, ['obs', 'obs-tn']); console.log(`getCatalog - found ${newCatalogReleases_.length} items in catalog next after filter`); // merge catalogs together - catalog new takes precedence for (const item of newCatalogReleases_) { const index = catalogReleases_.findIndex(function (oldItem) { return item.full_name === oldItem.full_name && item.full_name === oldItem.full_name; }); if (index >= 0) { catalogReleases_[index] = item; // overwrite item in old catalog catalogReleases_[index].foundInCatalog = 'NEW+OLD'; } else { catalogReleases_.push(item); // add unique item } } console.log(`getCatalog - now ${catalogReleases_.length} items in merged catalog, before filter`); // combine tw and twl dependencies catalogReleases_ = combineTwords(catalogReleases_); console.log(`getCatalog - now ${catalogReleases_.length} items in filtered catalog`); return catalogReleases_; }); return function getCatalog() { return _ref6.apply(this, arguments); }; })(); /** * add parameter to url string * @param {*} value * @param {object} parameters * @param {string} tag * @return {*} */ /** * @typedef {Object} searchParamsType * @property {String} owner - resource owner, if undefined then all are searched * @property {String} languageId - language of resource, if undefined then all are searched * @property {String} subject - one or more subjects separated by comma. See options defined in SUBJECT. * If undefined then all are searched. * @property {Number} limit - maximum results to return, default 100 * @property {String} partialMatch - if true will do case insensitive, substring matching, default is false * @property {String} stage - specifies which release stage to be returned out of these stages: * STAGE.PROD - return only the production releases * STAGE.PRE_PROD - return the pre-production release if it exists instead of the production release * STAGE.DRAFT - return the draft release if it exists instead of pre-production or production release * STAGE.LATEST -return the default branch (e.g. master) if it is a valid RC instead of the "prod", "preprod" or "draft". (default) * @property {Number|String} checkingLevel - search only for entries with the given checking level(s). Can be 1, 2 or 3. Default is any. * @property {String} sort - how to sort results (see defines in SORT), if undefined then sorted by by "lang", then "subject" and then "tag" * @property {String|String[]} topic - filter by topic tags (e.g. "tc-ready") */ /** * Method to search for latest resources using catalog next * @param {searchParamsType} searchParams - search options * @param {number} retries - number of times to retry calling search API, default 3 * @return {Promise<*[]|null>} */ let searchCatalogNext = exports.searchCatalogNext = (() => { var _ref7 = _asyncToGenerator(function* (searchParams, retries = 3) { let result_ = null; const start = Date.now(); const { owner, languageId, subject, limit = SEARCH_LIMIT, stage = _index.STAGE.LATEST, checkingLevel, partialMatch, sort = _index.SORT.REPO_NAME, DCS_BASE_URL: baseUrl = DCS_BASE_URL, topic } = searchParams; try { let fetchUrl = `${baseUrl}/api/v1/catalog/search`; let parameters = ''; parameters = addUrlParameter(owner, parameters, 'owner'); parameters = addUrlParameter(languageId, parameters, 'lang'); parameters = addUrlParameter(subject, parameters, 'subject'); parameters = addUrlParameter(limit, parameters, 'limit'); parameters = addUrlParameter(stage, parameters, 'stage'); parameters = addUrlParameter(checkingLevel, parameters, 'checkingLevel'); parameters = addUrlParameter(partialMatch, parameters, 'partialMatch'); parameters = addUrlParameter('rc', parameters, 'metadataType'); parameters = addUrlParameter(sort, parameters, 'sort'); if (Array.isArray(topic)) { for (const topicItem of topic) { { parameters = addUrlParameter(topicItem, parameters, 'topic'); } } } else { parameters = addUrlParameter(topic, parameters, 'topic'); } if (parameters) { fetchUrl += '?' + parameters; } console.log(`Searching: ${fetchUrl}`); result_ = yield doMultipartQuery(fetchUrl, retries); const end = Date.now(); const elapsed = (end - start) / (60 * 1000); // Convert milliseconds to minutes console.log(`searchCatalogNext() - search took: ${elapsed} minutes`); } catch (e) { console.warn('searchCatalogNext() - error calling search API', e); return null; } const supported = getCompatibleResourceList(result_); return supported; }); return function searchCatalogNext(_x6) { return _ref7.apply(this, arguments); }; })(); /** * does Catalog next API query to get manifest data * @param {string} owner * @param {string} repo * @param {string} tag * @param {number} retries * @param {string} baseUrl - optional server to use * @return {Promise<{Object}>} */ let downloadManifestData = exports.downloadManifestData = (() => { var _ref8 = _asyncToGenerator(function* ({ owner, repo, tag = 'master', retries = 5, baseUrl = DCS_BASE_URL }) { const fetchUrl = `${baseUrl}/api/v1/catalog/entry/${owner}/${repo}/${tag}/metadata`; try { const { result } = yield makeJsonRequestDetailed(fetchUrl, retries); return result; } catch (e) { console.warn('downloadManifestData() - error getting manifest data', e); throw e; } }); return function downloadManifestData(_x7) { return _ref8.apply(this, arguments); }; })(); /** * does Catalog next API query to get metadata for latest version * @param {string} owner * @param {string} repo * @param {number} retries * @param {string} stage - null defaults to production * @param {string} baseUrl - optional server to use * @return {Promise<{Object}>} */ let getLatestRelease = exports.getLatestRelease = (() => { var _ref9 = _asyncToGenerator(function* ({ owner, repo, retries = 5, stage = null, baseUrl = DCS_BASE_URL }) { let fetchUrl = `${baseUrl}/api/v1/catalog/search/${owner}/${repo}`; if (stage) { fetchUrl += `?stage=${stage}`; } try { const { result } = yield makeJsonRequestDetailed(fetchUrl, retries); if (result.data[0] && result.data[0].release) { return result.data[0]; } console.warn(`getLatestRelease() - error getting release data, empty response to ${fetchUrl}`); return null; } catch (e) { console.warn(`getLatestRelease() - error getting release data from ${fetchUrl}`, e); throw e; } }); return function getLatestRelease(_x8) { return _ref9.apply(this, arguments); }; })(); /** * does Catalog next API query to get metadata for tag * @param {string} owner * @param {string} repo * @param {string|null} tag * @param {number} retries * @param {string} baseUrl - optional server to use * @return {Promise<{Object}>} */ let getReleaseMetaData = exports.getReleaseMetaData = (() => { var _ref10 = _asyncToGenerator(function* ({ owner, repo, tag, retries = 5, baseUrl = DCS_BASE_URL }) { const fetchUrl = `${baseUrl}/api/v1/catalog/entry/${owner}/${repo}/${tag}`; try { const { result } = yield makeJsonRequestDetailed(fetchUrl, retries); return result; } catch (e) { console.warn('getReleaseMetaData() - error getting manifest data', e); throw e; } }); return function getReleaseMetaData(_x9) { return _ref10.apply(this, arguments); }; })(); /** * reads Json file if it exists, otherwise returns null * @param {string} jsonPath * @return {object|null} */ exports.combineTwords = combineTwords; exports.readJsonFile = readJsonFile; exports.getOwnerForOriginalLanguage = getOwnerForOriginalLanguage; exports.formatVersionWithV = formatVersionWithV; exports.formatVersionWithoutV = formatVersionWithoutV; var _utils = require('./utils'); var _index = require('../index'); var _fsExtra = require('fs-extra'); var _fsExtra2 = _interopRequireDefault(_fsExtra); var _pathExtra = require('path-extra'); var _pathExtra2 = _interopRequireDefault(_pathExtra); var _bible = require('../resources/bible'); var Bible = _interopRequireWildcard(_bible); var _resourcesHelpers = require('./resourcesHelpers'); var _parseHelpers = require('./parseHelpers'); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _asyncToGenerator(fn) { return function () { var gen = fn.apply(this, arguments); return new Promise(function (resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; } const request = require('request'); const DOOR43_CATALOG = exports.DOOR43_CATALOG = `Door43-Catalog`; const CN_CATALOG = exports.CN_CATALOG = `unfoldingWord`; const UNFOLDING_WORD = exports.UNFOLDING_WORD = `unfoldingWord`; const DEFAULT_OWNER = exports.DEFAULT_OWNER = DOOR43_CATALOG; const TRANSLATION_HELPS = exports.TRANSLATION_HELPS = 'translationHelps'; const EMPTY_TIME = exports.EMPTY_TIME = '0001-01-01T00:00:00+00:00'; const OWNER_SEPARATOR = exports.OWNER_SEPARATOR = '_'; const SEARCH_LIMIT = exports.SEARCH_LIMIT = 250; const DCS_BASE_URL = exports.DCS_BASE_URL = 'https://git.door43.org';function findResource(resourceList, resource) { const foundResource = resourceList.find(item => { return item.owner === resource.owner && item.language === resource.language && item.resourceId === resource.resourceId; }); return foundResource; } /** * merge twl resource downloads into tw downloads * @param {Array} catalogReleases * @return {*} */ function combineTwords(catalogReleases) { return catalogReleases.filter(resource => { if (resource.owner !== 'Door43-Catalog') { switch (resource.resourceId) { case 'twl': { const twResource = findResource(catalogReleases, _extends({}, resource, { resourceId: 'tw' })); if (twResource) { twResource.loadAfter = [resource]; } else { return false; // if no tw available, we cannot use the twl } break; } case 'tw': { const twlResource = findResource(catalogReleases, _extends({}, resource, { resourceId: 'twl' })); if (twlResource) { resource.loadAfter = [twlResource]; } else { return false; // if no twl available, we cannot use the tw } } break; } } return true; }); } /** * check if tag name is valid for release version * @param {string} tagName * @return {boolean} */ function isValidVersionTag(tagName) { const firstChar = tagName && tagName[0]; const isDigit = firstChar >= '0' && firstChar <= '9'; const isValidVersionTag = firstChar && (firstChar === 'v' || isDigit); return isValidVersionTag; } /** * filter list of items and ignore master branch * @param {[object]} catalog - array of resources * @param {[string]} ignoredResources * @param {[object]} newCatalog - new catalog to search for matches to Door43-Catalog matches in unfoldingWord * @return {[object]} */ function filterOutMasterBranch(catalog, ignoredResources = [], newCatalog = []) { let catalog_ = catalog.filter(resource => { if (ignoredResources.includes(resource.resource)) { return false; // reject ignored resources } const tagName = resource.branch_or_tag_name; if (tagName) { // check for version const _isValidVersionTag = isValidVersionTag(tagName); const isD43Master = tagName === 'master' && resource.owner === DOOR43_CATALOG; if (isD43Master) { // find the latest release in unfoldingWord org to get version info const sourceResource = findResource(newCatalog, _extends({}, resource, { owner: UNFOLDING_WORD })); if (sourceResource) { const _tagName = sourceResource.branch_or_tag_name; if (isValidVersionTag(_tagName)) { // copy latest release data from source resource.branch_or_tag_name = _tagName; resource.released = sourceResource.released; resource.modified = sourceResource.modified; resource.zipball_url = sourceResource.zipball_url; resource.downloadUrl = sourceResource.downloadUrl; return true; } } } else if (_isValidVersionTag) { return true; } console.log(`filterOutMasterBranch - invalid version: ${tagName} in ${(0, _parseHelpers.getResourceInfo)(resource)}`); return false; // reject if tag is not a version } return true; }); return catalog_; }function addUrlParameter(value, parameters, tag) { if (value) { if (parameters) { // see if we need separator parameters += '&'; } parameters += `${tag}=${encodeURIComponent(value)}`; } return parameters; } /** * filter for supported repos * @param {array} resources * @return {*[]} */ function getCompatibleResourceList(resources) { const supported = []; for (const item of resources || []) { // add fields for backward compatibility const languageId = item.language; let [, resourceId] = (item.name || '').split(`${languageId}_`); resourceId = resourceId || item.name; // if language was not in name, then use name as resource ID item.resourceId = resourceId; item.languageId = languageId; item.checking_level = item.repo && item.repo.checking_level; item.foundInCatalog = 'NEW'; item.modified = item.modified || item.released; if (item.zipball_url) { item.downloadUrl = item.zipball_url; } // check for version. if there is one, it will save having to fetch it from DCS later. if (item.release) { // if released const tagName = item.release.tag_name; if (tagName && tagName[0] === 'v') { item.version = tagName.substr(1); } } else { const branchOrTagName = item.branch_or_tag_name; if (branchOrTagName && branchOrTagName[0] === 'v') { item.version = branchOrTagName.substr(1); } } if (item.subject) { item.subject = item.subject.replaceAll(' ', '_'); } // add supported resources to returned list if (item.downloadUrl && item.subject && item.name && item.full_name) { supported.push(item); } } return supported; }function readJsonFile(jsonPath) { if (_fsExtra2.default.existsSync(jsonPath)) { try { const resourceManifest = _fsExtra2.default.readJsonSync(jsonPath); return resourceManifest; } catch (e) { console.error(`getLocalResourceList(): could not read ${jsonPath}`, e); } } return null; } /** * add resource to list * @param {string} resourceLatestPath * @param {string} pathToResourceManifestFile * @param {string} languageId * @param {string} resourceId * @param {array} localResourceList */ function addLocalResource(resourceLatestPath, pathToResourceManifestFile, languageId, resourceId, localResourceList) { const { version, owner } = (0, _resourcesHelpers.getVersionAndOwnerFromPath)(resourceLatestPath); const resourceManifest = readJsonFile(pathToResourceManifestFile); if (resourceManifest) { const localResource = { languageId, resourceId, owner, version, modifiedTime: resourceManifest.catalog_modified_time, manifest: resourceManifest }; localResourceList.push(localResource); } else { console.log(`addLocalResource(): no such file or directory, ${pathToResourceManifestFile}`); } } /** * get local resources * @param {string} resourcesPath * @return {null|*[]} */ const getLocalResourceList = exports.getLocalResourceList = resourcesPath => { try { if (!_fsExtra2.default.existsSync(resourcesPath)) { _fsExtra2.default.ensureDirSync(resourcesPath); } const localResourceList = []; const resourceLanguages = _fsExtra2.default.readdirSync(resourcesPath).filter(file => _pathExtra2.default.extname(file) !== '.json' && file !== '.DS_Store'); for (let i = 0; i < resourceLanguages.length; i++) { const languageId = resourceLanguages[i]; const biblesPath = _pathExtra2.default.join(resourcesPath, languageId, 'bibles'); const tHelpsPath = _pathExtra2.default.join(resourcesPath, languageId, TRANSLATION_HELPS); const bibleIds = (0, _resourcesHelpers.cleanReaddirSync)(biblesPath); const tHelpsResources = (0, _resourcesHelpers.cleanReaddirSync)(tHelpsPath); bibleIds.forEach(bibleId => { const bibleIdPath = _pathExtra2.default.join(biblesPath, bibleId); const owners = (0, _resourcesHelpers.getLatestVersionsAndOwners)(bibleIdPath) || {}; for (const owner of Object.keys(owners)) { const bibleLatestVersion = owners[owner]; if (bibleLatestVersion) { const pathToBibleManifestFile = _pathExtra2.default.join(bibleLatestVersion, 'manifest.json'); addLocalResource(bibleLatestVersion, pathToBibleManifestFile, languageId, bibleId, localResourceList); } else { console.log(`getLocalResourceList(): bibleLatestVersion is not found.`); } } }); tHelpsResources.forEach(tHelpsId => { const tHelpResource = _pathExtra2.default.join(tHelpsPath, tHelpsId); const latestVersions = (0, _resourcesHelpers.getLatestVersionsAndOwners)(tHelpResource) || {}; const resourceId = _parseHelpers.RESOURCE_ID_MAP[tHelpsId] || tHelpsId; // map resource names to ids for (const owner of Object.keys(latestVersions)) { const tHelpsLatestVersion = latestVersions[owner]; if (tHelpsLatestVersion) { const pathTotHelpsManifestFile = _pathExtra2.default.join(tHelpsLatestVersion, 'manifest.json'); addLocalResource(tHelpsLatestVersion, pathTotHelpsManifestFile, languageId, resourceId, localResourceList); } else { console.log(`getLocalResourceList(): tHelpsLatestVersion is ${tHelpsLatestVersion}.`); } } }); } return localResourceList; } catch (error) { console.error(error); return null; } }; /** * determine owner for original language resource. If resource owner is door43 Catalog, then the original * language resource will be the same. Otherwise it will be unfoldingWord. * @param {string} resourceOwner * @return {string} */ function getOwnerForOriginalLanguage(resourceOwner) { const origLangOwner = resourceOwner !== DOOR43_CATALOG ? CN_CATALOG : DOOR43_CATALOG; return origLangOwner; } /** * make sure version starts with v * @param {string} version * @return {string} */ function formatVersionWithV(version) { const version_ = version[0] === 'v' ? version : 'v' + version; return version_; } /** * make sure version does not start with v * @param {string} version * @return {string} */ function formatVersionWithoutV(version) { const version_ = version[0] === 'v' ? version.substring(1) : version; return version_; }