Initial commit of 001code-html Scratch frontend project.
Includes scratch-gui, scratch-vm, scratch-blocks, scratch-render, scratch-l10n, and deployment config. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
107
scratch-l10n/scripts/build-data.js
Executable file
107
scratch-l10n/scripts/build-data.js
Executable file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/*
|
||||
Generates locales/<component>-msgs.js for each component (gui, etc) from the
|
||||
current translation files for each language for that component
|
||||
|
||||
Translation files are expected to be in Chrome i18n json format:
|
||||
'''
|
||||
{
|
||||
"message.id": {
|
||||
"message": "The translated text",
|
||||
"description": "Tips for translators"
|
||||
},
|
||||
...
|
||||
}
|
||||
'''
|
||||
They are named by locale, for example: 'fr.json' or 'zh-cn.json'
|
||||
|
||||
Converts the collection of translation files to a single set of messages.
|
||||
Example output:
|
||||
'''
|
||||
{
|
||||
"en": {
|
||||
"action.addBackdrop": "Add Backdrop",
|
||||
"action.addCostume": "Add Costume",
|
||||
"action.recordSound": "Record Sound",
|
||||
"action.addSound": "Add Sound"
|
||||
},
|
||||
"fr": {
|
||||
"action.addSound": "Ajouter Son",
|
||||
"action.addCostume": "Ajouter Costume",
|
||||
"action.addBackdrop": "Ajouter Arrière-plan",
|
||||
"action.recordSound": "Enregistrement du Son"
|
||||
}
|
||||
}
|
||||
'''
|
||||
NOTE: blocks messages are plain key-value JSON files
|
||||
|
||||
Missing locales are ignored, react-intl will use the default messages for them.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {sync as mkdirpSync} from 'mkdirp';
|
||||
import defaultsDeep from 'lodash.defaultsdeep';
|
||||
import locales from '../src/supported-locales.js';
|
||||
import allUsedIds from './tw-all-used-ids.json';
|
||||
|
||||
const MSGS_DIR = './locales/';
|
||||
mkdirpSync(MSGS_DIR);
|
||||
let missingLocales = [];
|
||||
|
||||
const combineJson = (component) => {
|
||||
return Object.keys(locales).reduce((collection, lang) => {
|
||||
try {
|
||||
let langData = JSON.parse(
|
||||
fs.readFileSync(path.resolve('editor', component, lang + '.json'), 'utf8')
|
||||
);
|
||||
// TW: Remove messages that we don't use.
|
||||
for (const key of Object.keys(langData)) {
|
||||
if (!allUsedIds.includes(key)) {
|
||||
delete langData[key];
|
||||
}
|
||||
}
|
||||
collection[lang] = langData;
|
||||
} catch (e) {
|
||||
missingLocales.push(component + ':' + lang + '\n');
|
||||
}
|
||||
return collection;
|
||||
}, {});
|
||||
};
|
||||
|
||||
// generate the blocks messages: files are plain key-value JSON
|
||||
let blocksMessages = combineJson('blocks');
|
||||
let blockData =
|
||||
'// GENERATED FILE:\n' +
|
||||
'export default ' +
|
||||
JSON.stringify(blocksMessages, null, 2) +
|
||||
';\n';
|
||||
|
||||
fs.writeFileSync(MSGS_DIR + 'blocks-msgs.js', blockData);
|
||||
|
||||
// generate messages for gui components - all files are plain key-value JSON
|
||||
let components = ['interface', 'extensions', 'paint-editor'];
|
||||
let editorMsgs = {};
|
||||
components.forEach((component) => {
|
||||
let messages = combineJson(component);
|
||||
let data =
|
||||
'// GENERATED FILE:\n' +
|
||||
'export default ' +
|
||||
JSON.stringify(messages, null, 2) +
|
||||
';\n';
|
||||
fs.writeFileSync(MSGS_DIR + component + '-msgs.js', data);
|
||||
defaultsDeep(editorMsgs, messages);
|
||||
});
|
||||
|
||||
// generate combined editor-msgs file
|
||||
let editorData =
|
||||
'// GENERATED FILE:\n' +
|
||||
'export default ' +
|
||||
JSON.stringify(editorMsgs, null, 2) +
|
||||
';\n';
|
||||
fs.writeFileSync(MSGS_DIR + 'editor-msgs.js', editorData);
|
||||
|
||||
if (missingLocales.length > 0) {
|
||||
process.stdout.write('missing locales:\n' + missingLocales.toString());
|
||||
process.exit(1);
|
||||
}
|
||||
45
scratch-l10n/scripts/build-i18n-src.js
Executable file
45
scratch-l10n/scripts/build-i18n-src.js
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const glob = require('glob');
|
||||
const path = require('path');
|
||||
const mkdirp = require('mkdirp');
|
||||
|
||||
var args = process.argv.slice(2);
|
||||
|
||||
if (!args.length) {
|
||||
process.stdout.write('You must specify the messages dir generated by babel-plugin-react-intl.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const MESSAGES_PATTERN = args.shift() + '/**/*.json';
|
||||
|
||||
if (!args.length) {
|
||||
process.stdout.write('A destination directory must be specified.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const LANG_DIR = args.shift();
|
||||
|
||||
// Aggregates the default messages that were extracted from the example app's
|
||||
// React components via the React Intl Babel plugin. An error will be thrown if
|
||||
// there are messages in different components that use the same `id`. The result
|
||||
// is a chromei18n format collection of `id: {message: defaultMessage,
|
||||
// description: description}` pairs for the app's default locale.
|
||||
let defaultMessages = glob.sync(MESSAGES_PATTERN)
|
||||
.map((filename) => fs.readFileSync(filename, 'utf8'))
|
||||
.map((file) => JSON.parse(file))
|
||||
.reduce((collection, descriptors) => {
|
||||
descriptors.forEach(({id, defaultMessage, description}) => {
|
||||
if (Object.prototype.hasOwnProperty.call(collection, id)) {
|
||||
throw new Error(`Duplicate message id: ${id}`);
|
||||
}
|
||||
|
||||
collection[id] = {message: defaultMessage, description: description};
|
||||
});
|
||||
|
||||
return collection;
|
||||
}, {});
|
||||
|
||||
mkdirp.sync(LANG_DIR);
|
||||
fs.writeFileSync(path.join(LANG_DIR, 'en.json'), JSON.stringify(defaultMessages, null, 2));
|
||||
164
scratch-l10n/scripts/freshdesk-api.js
Normal file
164
scratch-l10n/scripts/freshdesk-api.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// interface to FreshDesk Solutions (knowledge base) api
|
||||
|
||||
const fetch = require('node-fetch');
|
||||
class FreshdeskApi {
|
||||
|
||||
constructor (baseUrl, apiKey) {
|
||||
this.baseUrl = baseUrl;
|
||||
this._auth = 'Basic ' + new Buffer(`${apiKey}:X`).toString('base64');
|
||||
this.defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': this._auth
|
||||
};
|
||||
this.rateLimited = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the status of a response. If status is not ok, or the body is not json raise exception
|
||||
* @param {object} res The response object
|
||||
* @returns {object} the response if it is ok
|
||||
*/
|
||||
checkStatus (res) {
|
||||
if (res.ok) {
|
||||
if (res.headers.get('content-type').indexOf('application/json') !== -1) {
|
||||
return res;
|
||||
}
|
||||
throw new Error(`response not json: ${res.headers.get('content-type')}`);
|
||||
}
|
||||
let err = new Error(`response ${res.statusText}`);
|
||||
err.code = res.status;
|
||||
if (res.status === 429) {
|
||||
err.retryAfter = res.headers.get('Retry-After');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
listCategories () {
|
||||
return fetch(`${this.baseUrl}/api/v2/solutions/categories`, {headers: this.defaultHeaders})
|
||||
.then(this.checkStatus)
|
||||
.then(res => res.json());
|
||||
}
|
||||
|
||||
listFolders (category) {
|
||||
return fetch(
|
||||
`${this.baseUrl}/api/v2/solutions/categories/${category.id}/folders`,
|
||||
{headers: this.defaultHeaders})
|
||||
.then(this.checkStatus)
|
||||
.then(res => res.json());
|
||||
}
|
||||
|
||||
listArticles (folder) {
|
||||
return fetch(
|
||||
`${this.baseUrl}/api/v2/solutions/folders/${folder.id}/articles`,
|
||||
{headers: this.defaultHeaders})
|
||||
.then(this.checkStatus)
|
||||
.then(res => res.json());
|
||||
}
|
||||
|
||||
updateCategoryTranslation (id, locale, body) {
|
||||
if (this.rateLimited) {
|
||||
process.stdout.write(`Rate limited, skipping id: ${id} for ${locale}\n`);
|
||||
return -1;
|
||||
}
|
||||
return fetch(
|
||||
`${this.baseUrl}/api/v2/solutions/categories/${id}/${locale}`,
|
||||
{
|
||||
method: 'put',
|
||||
body: JSON.stringify(body),
|
||||
headers: this.defaultHeaders
|
||||
})
|
||||
.then(this.checkStatus)
|
||||
.then(res => res.json())
|
||||
.catch((err) => {
|
||||
if (err.code === 404) {
|
||||
// not found, try create instead
|
||||
return fetch(
|
||||
`${this.baseUrl}/api/v2/solutions/categories/${id}/${locale}`,
|
||||
{
|
||||
method: 'post',
|
||||
body: JSON.stringify(body),
|
||||
headers: this.defaultHeaders
|
||||
})
|
||||
.then(this.checkStatus)
|
||||
.then(res => res.json());
|
||||
}
|
||||
if (err.code === 429) {
|
||||
this.rateLimited = true;
|
||||
}
|
||||
process.stdout.write(`Error processing id ${id} for locale ${locale}: ${err.message}\n`);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
updateFolderTranslation (id, locale, body) {
|
||||
if (this.rateLimited) {
|
||||
process.stdout.write(`Rate limited, skipping id: ${id} for ${locale}\n`);
|
||||
return -1;
|
||||
}
|
||||
return fetch(
|
||||
`${this.baseUrl}/api/v2/solutions/folders/${id}/${locale}`,
|
||||
{
|
||||
method: 'put',
|
||||
body: JSON.stringify(body),
|
||||
headers: this.defaultHeaders
|
||||
})
|
||||
.then(this.checkStatus)
|
||||
.then(res => res.json())
|
||||
.catch((err) => {
|
||||
if (err.code === 404) {
|
||||
// not found, try create instead
|
||||
return fetch(
|
||||
`${this.baseUrl}/api/v2/solutions/folders/${id}/${locale}`,
|
||||
{
|
||||
method: 'post',
|
||||
body: JSON.stringify(body),
|
||||
headers: this.defaultHeaders
|
||||
})
|
||||
.then(this.checkStatus)
|
||||
.then(res => res.json());
|
||||
}
|
||||
if (err.code === 429) {
|
||||
this.rateLimited = true;
|
||||
}
|
||||
process.stdout.write(`Error processing id ${id} for locale ${locale}: ${err.message}\n`);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
updateArticleTranslation (id, locale, body) {
|
||||
if (this.rateLimited) {
|
||||
process.stdout.write(`Rate limited, skipping id: ${id} for ${locale}\n`);
|
||||
return -1;
|
||||
}
|
||||
return fetch(
|
||||
`${this.baseUrl}/api/v2/solutions/articles/${id}/${locale}`,
|
||||
{
|
||||
method: 'put',
|
||||
body: JSON.stringify(body),
|
||||
headers: this.defaultHeaders
|
||||
})
|
||||
.then(this.checkStatus)
|
||||
.then(res => res.json())
|
||||
.catch((err) => {
|
||||
if (err.code === 404) {
|
||||
// not found, try create instead
|
||||
return fetch(
|
||||
`${this.baseUrl}/api/v2/solutions/articles/${id}/${locale}`,
|
||||
{
|
||||
method: 'post',
|
||||
body: JSON.stringify(body),
|
||||
headers: this.defaultHeaders
|
||||
})
|
||||
.then(this.checkStatus)
|
||||
.then(res => res.json());
|
||||
}
|
||||
if (err.code === 429) {
|
||||
this.rateLimited = true;
|
||||
}
|
||||
process.stdout.write(`Error processing id ${id} for locale ${locale}: ${err.message}\n`);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FreshdeskApi;
|
||||
200
scratch-l10n/scripts/help-utils.js
Normal file
200
scratch-l10n/scripts/help-utils.js
Normal file
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
* Helper functions for syncing Freshdesk knowledge base articles with Transifex
|
||||
*/
|
||||
|
||||
const FreshdeskApi = require('./freshdesk-api.js');
|
||||
const fs = require('fs');
|
||||
const fsPromises = fs.promises;
|
||||
const mkdirp = require('mkdirp');
|
||||
const {txPull, txResourcesObjects, txAvailableLanguages} = require('../lib/transifex.js');
|
||||
|
||||
const FD = new FreshdeskApi('https://mitscratch.freshdesk.com', process.env.FRESHDESK_TOKEN);
|
||||
const TX_PROJECT = 'scratch-help';
|
||||
|
||||
const freshdeskLocale = locale => {
|
||||
// map between Transifex locale and Freshdesk. Two letter codes are usually fine
|
||||
const localeMap = {
|
||||
'es_419': 'es-LA',
|
||||
'ja': 'ja-JP',
|
||||
'ja-Hira': 'ja-JP',
|
||||
'lv': 'lv-LV',
|
||||
'nb': 'nb-NO',
|
||||
'nn': 'nb-NO',
|
||||
'pt': 'pt-PT',
|
||||
'pt_BR': 'pt-BR',
|
||||
'ru': 'ru-RU',
|
||||
'sv': 'sv-SE',
|
||||
'zh_CN': 'zh-CN',
|
||||
'zh_TW': 'zh-TW'
|
||||
};
|
||||
return localeMap[locale] || locale;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Pull metadata from Transifex for the scratch-help project
|
||||
* @return {Promise} results array containing:
|
||||
* languages: array of supported languages
|
||||
* folders: array of tx resources corrsponding to Freshdesk folders
|
||||
* names: array of tx resources corresponding to the Freshdesk metadata
|
||||
*/
|
||||
exports.getInputs = async () => {
|
||||
const resources = await txResourcesObjects(TX_PROJECT);
|
||||
const languages = await txAvailableLanguages(TX_PROJECT);
|
||||
// there are three types of resources differentiated by the file type
|
||||
const folders = resources.filter(resource => resource.i18n_type === 'STRUCTURED_JSON');
|
||||
const names = resources.filter(resource => resource.i18n_type === 'KEYVALUEJSON');
|
||||
// ignore the yaml type because it's not possible to update via API
|
||||
|
||||
return Promise.all([languages, folders, names]); // eslint-disable-line no-undef
|
||||
};
|
||||
|
||||
/**
|
||||
* internal function to serialize saving category and folder name translations to avoid Freshdesk rate limit
|
||||
* @param {[type]} json [description]
|
||||
* @param {[type]} resource [description]
|
||||
* @param {[type]} locale [description]
|
||||
* @return {Promise} [description]
|
||||
*/
|
||||
const serializeNameSave = async (json, resource, locale) => {
|
||||
for (let [key, value] of Object.entries(json)) {
|
||||
// key is of the form <name>_<id>
|
||||
const words = key.split('_');
|
||||
const id = words[words.length - 1];
|
||||
let status = 0;
|
||||
if (resource.name === 'categoryNames_json') {
|
||||
status = await FD.updateCategoryTranslation(id, freshdeskLocale(locale), {name: value});
|
||||
}
|
||||
if (resource.name === 'folderNames_json') {
|
||||
status = await FD.updateFolderTranslation(id, freshdeskLocale(locale), {name: value});
|
||||
}
|
||||
if (status === -1) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal function serialize Freshdesk requests to avoid getting rate limited
|
||||
* @param {object} json object with keys corresponding to article ids
|
||||
* @param {string} locale language code
|
||||
* @return {Promise} [description]
|
||||
*/
|
||||
const serializeFolderSave = async (json, locale) => {
|
||||
// json is a map of articles:
|
||||
// {
|
||||
// <id>: {
|
||||
// title: {string: <title-value>},
|
||||
// description: {string: <description-value>},
|
||||
// tags: {string: <comma separated strings} // optional
|
||||
// },
|
||||
// <id>: {
|
||||
// title: {string: <title-value>},
|
||||
// description: {string: <description-value>},
|
||||
// tags: {string: <comma separated strings} // optional
|
||||
// }
|
||||
// }
|
||||
for (let [id, value] of Object.entries(json)) {
|
||||
let body = {
|
||||
title: value.title.string,
|
||||
description: value.description.string,
|
||||
status: 2 // set status to published
|
||||
};
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'tags')) {
|
||||
let tags = value.tags.string.split(',');
|
||||
let validTags = tags.filter(tag => tag.length < 33);
|
||||
if (validTags.length !== tags.length) {
|
||||
process.stdout.write(`Warning: tags too long in ${id} for ${locale}\n`);
|
||||
}
|
||||
body.tags = validTags;
|
||||
}
|
||||
let status = await FD.updateArticleTranslation(id, freshdeskLocale(locale), body);
|
||||
if (status === -1) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Process Transifex resource corresponding to a Knowledge base folder on Freshdesk
|
||||
* @param {object} folder Transifex resource json corresponding to a KB folder
|
||||
* @param {string} locale locale to pull and submit to Freshdesk
|
||||
* @return {Promise} [description]
|
||||
*/
|
||||
exports.localizeFolder = async (folder, locale) => {
|
||||
txPull(TX_PROJECT, folder.slug, locale, {mode: 'default'})
|
||||
.then(data => {
|
||||
serializeFolderSave(data, locale);
|
||||
})
|
||||
.catch((e) => {
|
||||
process.stdout.write(`Error processing ${folder.slug}, ${locale}: ${e.message}\n`);
|
||||
process.exitCode = 1; // not ok
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Save Transifex resource corresponding to a Knowledge base folder locally for debugging
|
||||
* @param {object} folder Transifex resource json corresponding to a KB folder
|
||||
* @param {string} locale locale to pull and save
|
||||
* @return {Promise} [description]
|
||||
*/
|
||||
exports.debugFolder = async (folder, locale) => {
|
||||
mkdirp.sync('tmpDebug');
|
||||
txPull(TX_PROJECT, folder.slug, locale, {mode: 'default'})
|
||||
.then(data => {
|
||||
fsPromises.writeFile(
|
||||
`tmpDebug/${folder.slug}_${locale}.json`,
|
||||
JSON.stringify(data, null, 2)
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
process.stdout.write(`Error processing ${folder.slug}, ${locale}: ${e.message}\n`);
|
||||
process.exitCode = 1; // not ok
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Process KEYVALUEJSON resources from scratch-help on transifex
|
||||
* Category and Folder names are stored as plain json
|
||||
* @param {object} resource Transifex resource json for either CategoryNames or FolderNames
|
||||
* @param {string} locale locale to pull and submit to Freshdesk
|
||||
* @return {Promise} [description]
|
||||
*/
|
||||
exports.localizeNames = async (resource, locale) => {
|
||||
txPull(TX_PROJECT, resource.slug, locale, {mode: 'default'})
|
||||
.then(data => {
|
||||
serializeNameSave(data, resource, locale);
|
||||
})
|
||||
.catch((e) => {
|
||||
process.stdout.write(`Error saving ${resource.slug}, ${locale}: ${e.message}\n`);
|
||||
process.exitCode = 1; // not ok
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const BATCH_SIZE = 2;
|
||||
/*
|
||||
* save resource items in batches to reduce rate limiting errors
|
||||
* @param {object} item Transifex resource json, used for 'slug'
|
||||
* @param {array} languages Array of languages to save
|
||||
* @param {function} saveFn Async function to use to save the item
|
||||
* @return {Promise}
|
||||
*/
|
||||
exports.saveItem = async (item, languages, saveFn) => {
|
||||
const saveLanguages = languages.filter(l => l !== 'en'); // exclude English from update
|
||||
let batchedPromises = Promise.resolve(); // eslint-disable-line no-undef
|
||||
for (let i = 0; i < saveLanguages.length; i += BATCH_SIZE) {
|
||||
batchedPromises = batchedPromises
|
||||
.then(() => Promise.all( // eslint-disable-line
|
||||
saveLanguages.slice(i, i + BATCH_SIZE).map(l => saveFn(item, l))
|
||||
))
|
||||
.catch(err => {
|
||||
process.stdout.write(`Error saving item:${err.message}\n${JSON.stringify(item, null, 2)}\n`);
|
||||
process.exitCode = 1; // not ok
|
||||
});
|
||||
}
|
||||
};
|
||||
816
scratch-l10n/scripts/tw-all-used-ids.json
Normal file
816
scratch-l10n/scripts/tw-all-used-ids.json
Normal file
@@ -0,0 +1,816 @@
|
||||
[
|
||||
"boost.color.any",
|
||||
"boost.color.black",
|
||||
"boost.color.blue",
|
||||
"boost.color.green",
|
||||
"boost.color.red",
|
||||
"boost.color.white",
|
||||
"boost.color.yellow",
|
||||
"boost.getMotorPosition",
|
||||
"boost.getTiltAngle",
|
||||
"boost.motorDirection.backward",
|
||||
"boost.motorDirection.forward",
|
||||
"boost.motorDirection.reverse",
|
||||
"boost.motorOff",
|
||||
"boost.motorOn",
|
||||
"boost.motorOnFor",
|
||||
"boost.motorOnForRotation",
|
||||
"boost.seeingColor",
|
||||
"boost.setLightHue",
|
||||
"boost.setMotorDirection",
|
||||
"boost.setMotorPower",
|
||||
"boost.tiltDirection.any",
|
||||
"boost.tiltDirection.down",
|
||||
"boost.tiltDirection.left",
|
||||
"boost.tiltDirection.right",
|
||||
"boost.tiltDirection.up",
|
||||
"boost.whenColor",
|
||||
"boost.whenTilted",
|
||||
"ev3.beepNote",
|
||||
"ev3.buttonPressed",
|
||||
"ev3.getBrightness",
|
||||
"ev3.getDistance",
|
||||
"ev3.getMotorPosition",
|
||||
"ev3.motorSetPower",
|
||||
"ev3.motorTurnClockwise",
|
||||
"ev3.motorTurnCounterClockwise",
|
||||
"ev3.whenBrightnessLessThan",
|
||||
"ev3.whenButtonPressed",
|
||||
"ev3.whenDistanceLessThan",
|
||||
"gdxfor.getAcceleration",
|
||||
"gdxfor.getForce",
|
||||
"gdxfor.getSpin",
|
||||
"gdxfor.getTilt",
|
||||
"gdxfor.isFreeFalling",
|
||||
"gdxfor.isTilted",
|
||||
"gdxfor.pulled",
|
||||
"gdxfor.pushed",
|
||||
"gdxfor.shaken",
|
||||
"gdxfor.startedFalling",
|
||||
"gdxfor.tiltDirectionMenu.any",
|
||||
"gdxfor.tiltDirectionMenu.back",
|
||||
"gdxfor.tiltDirectionMenu.front",
|
||||
"gdxfor.tiltDirectionMenu.left",
|
||||
"gdxfor.tiltDirectionMenu.right",
|
||||
"gdxfor.turnedFaceDown",
|
||||
"gdxfor.turnedFaceUp",
|
||||
"gdxfor.whenForcePushedOrPulled",
|
||||
"gdxfor.whenGesture",
|
||||
"gdxfor.whenTilted",
|
||||
"gui.SpriteInfo.direction",
|
||||
"gui.SpriteInfo.hideSpriteAction",
|
||||
"gui.SpriteInfo.show",
|
||||
"gui.SpriteInfo.showSpriteAction",
|
||||
"gui.SpriteInfo.size",
|
||||
"gui.SpriteInfo.sprite",
|
||||
"gui.SpriteInfo.spritePlaceholder",
|
||||
"gui.alerts.cloudInfo",
|
||||
"gui.alerts.cloudInfoLearnMore",
|
||||
"gui.alerts.createcopysuccess",
|
||||
"gui.alerts.createremixsuccess",
|
||||
"gui.alerts.createsuccess",
|
||||
"gui.alerts.creating",
|
||||
"gui.alerts.creatingCopy",
|
||||
"gui.alerts.creatingError",
|
||||
"gui.alerts.creatingRemix",
|
||||
"gui.alerts.download",
|
||||
"gui.alerts.importing",
|
||||
"gui.alerts.savesuccess",
|
||||
"gui.alerts.saving",
|
||||
"gui.alerts.savingError",
|
||||
"gui.alerts.tryAgain",
|
||||
"gui.authorInfo.byUser",
|
||||
"gui.backpack.costumeLabel",
|
||||
"gui.backpack.emptyBackpack",
|
||||
"gui.backpack.errorBackpack",
|
||||
"gui.backpack.header",
|
||||
"gui.backpack.loadingBackpack",
|
||||
"gui.backpack.more",
|
||||
"gui.backpack.scriptLabel",
|
||||
"gui.backpack.soundLabel",
|
||||
"gui.backpack.spriteLabel",
|
||||
"gui.cards.all-tutorials",
|
||||
"gui.cards.close",
|
||||
"gui.cards.expand",
|
||||
"gui.cards.more-things-to-try",
|
||||
"gui.cards.see-more",
|
||||
"gui.cards.shrink",
|
||||
"gui.comingSoon.message1",
|
||||
"gui.comingSoon.message2",
|
||||
"gui.comingSoon.message3",
|
||||
"gui.connection.auto-scanning.noPeripheralsFound",
|
||||
"gui.connection.auto-scanning.prescan",
|
||||
"gui.connection.auto-scanning.pressbutton",
|
||||
"gui.connection.auto-scanning.start-search",
|
||||
"gui.connection.auto-scanning.try-again",
|
||||
"gui.connection.auto-scanning.updatePeripheralButton",
|
||||
"gui.connection.connect",
|
||||
"gui.connection.connected",
|
||||
"gui.connection.connecting-cancelbutton",
|
||||
"gui.connection.connecting-searchbutton",
|
||||
"gui.connection.disconnect",
|
||||
"gui.connection.error.errorMessage",
|
||||
"gui.connection.error.helpbutton",
|
||||
"gui.connection.error.tryagainbutton",
|
||||
"gui.connection.go-to-editor",
|
||||
"gui.connection.peripheral-name-label",
|
||||
"gui.connection.reconnect",
|
||||
"gui.connection.scanning.instructions",
|
||||
"gui.connection.scanning.lookingforperipherals",
|
||||
"gui.connection.scanning.noPeripheralsFound",
|
||||
"gui.connection.scanning.updatePeripheralButton",
|
||||
"gui.connection.search",
|
||||
"gui.connection.unavailable.enablebluetooth",
|
||||
"gui.connection.unavailable.helpbutton",
|
||||
"gui.connection.unavailable.installscratchlink",
|
||||
"gui.connection.unavailable.tryagainbutton",
|
||||
"gui.connection.updatePeripheral.goBackButton",
|
||||
"gui.connection.updatePeripheral.microBitConnect",
|
||||
"gui.connection.updatePeripheral.pressUpdate",
|
||||
"gui.connection.updatePeripheral.progress",
|
||||
"gui.connection.updatePeripheral.updateAgainButton",
|
||||
"gui.connection.updatePeripheral.updateFailed",
|
||||
"gui.connection.updatePeripheral.updateNowButton",
|
||||
"gui.connection.updatePeripheral.updateSuccessful",
|
||||
"gui.controls.go",
|
||||
"gui.controls.stop",
|
||||
"gui.costumeLibrary.chooseABackdrop",
|
||||
"gui.costumeLibrary.chooseACostume",
|
||||
"gui.costumeTab.addBackdropFromLibrary",
|
||||
"gui.costumeTab.addBlankCostume",
|
||||
"gui.costumeTab.addCostumeFromLibrary",
|
||||
"gui.costumeTab.addFileBackdrop",
|
||||
"gui.costumeTab.addFileCostume",
|
||||
"gui.costumeTab.addSurpriseCostume",
|
||||
"gui.crashMessage.errorNumber",
|
||||
"gui.crashMessage.label",
|
||||
"gui.crashMessage.reload",
|
||||
"gui.customProcedures.addALabel",
|
||||
"gui.customProcedures.addAnInputBoolean",
|
||||
"gui.customProcedures.addAnInputNumberText",
|
||||
"gui.customProcedures.booleanType",
|
||||
"gui.customProcedures.cancel",
|
||||
"gui.customProcedures.myblockModalTitle",
|
||||
"gui.customProcedures.numberTextType",
|
||||
"gui.customProcedures.ok",
|
||||
"gui.customProcedures.runWithoutScreenRefresh",
|
||||
"gui.defaultProject.variable",
|
||||
"gui.directionPicker.rotationStyles.allAround",
|
||||
"gui.directionPicker.rotationStyles.dontRotate",
|
||||
"gui.directionPicker.rotationStyles.leftRight",
|
||||
"gui.extension.boost.connectingMessage",
|
||||
"gui.extension.boost.description",
|
||||
"gui.extension.ev3.connectingMessage",
|
||||
"gui.extension.ev3.description",
|
||||
"gui.extension.gdxfor.connectingMessage",
|
||||
"gui.extension.gdxfor.description",
|
||||
"gui.extension.makeymakey.description",
|
||||
"gui.extension.microbit.connectingMessage",
|
||||
"gui.extension.microbit.description",
|
||||
"gui.extension.music.description",
|
||||
"gui.extension.music.name",
|
||||
"gui.extension.pen.description",
|
||||
"gui.extension.pen.name",
|
||||
"gui.extension.text2speech.description",
|
||||
"gui.extension.text2speech.name",
|
||||
"gui.extension.translate.description",
|
||||
"gui.extension.translate.name",
|
||||
"gui.extension.videosensing.description",
|
||||
"gui.extension.videosensing.name",
|
||||
"gui.extension.wedo2.connectingMessage",
|
||||
"gui.extension.wedo2.description",
|
||||
"gui.extensionLibrary.chooseAnExtension",
|
||||
"gui.extensionLibrary.collaboration",
|
||||
"gui.extensionLibrary.comingSoon",
|
||||
"gui.extensionLibrary.requires",
|
||||
"gui.gui.addExtension",
|
||||
"gui.gui.backdropsTab",
|
||||
"gui.gui.cloudVariableOption",
|
||||
"gui.gui.codeTab",
|
||||
"gui.gui.costumesTab",
|
||||
"gui.gui.listPromptAllSpritesMessage",
|
||||
"gui.gui.projectTitlePlaceholder",
|
||||
"gui.gui.soundsTab",
|
||||
"gui.gui.variablePromptAllSpritesMessage",
|
||||
"gui.gui.variableScopeOptionAllSprites",
|
||||
"gui.gui.variableScopeOptionSpriteOnly",
|
||||
"gui.library.allTag",
|
||||
"gui.library.filterPlaceholder",
|
||||
"gui.libraryTags.all",
|
||||
"gui.libraryTags.animals",
|
||||
"gui.libraryTags.animation",
|
||||
"gui.libraryTags.art",
|
||||
"gui.libraryTags.dance",
|
||||
"gui.libraryTags.effects",
|
||||
"gui.libraryTags.fantasy",
|
||||
"gui.libraryTags.fashion",
|
||||
"gui.libraryTags.food",
|
||||
"gui.libraryTags.games",
|
||||
"gui.libraryTags.indoors",
|
||||
"gui.libraryTags.letters",
|
||||
"gui.libraryTags.loops",
|
||||
"gui.libraryTags.music",
|
||||
"gui.libraryTags.notes",
|
||||
"gui.libraryTags.outdoors",
|
||||
"gui.libraryTags.patterns",
|
||||
"gui.libraryTags.people",
|
||||
"gui.libraryTags.percussion",
|
||||
"gui.libraryTags.space",
|
||||
"gui.libraryTags.sports",
|
||||
"gui.libraryTags.stories",
|
||||
"gui.libraryTags.underwater",
|
||||
"gui.libraryTags.voice",
|
||||
"gui.libraryTags.wacky",
|
||||
"gui.loader.creating",
|
||||
"gui.loader.headline",
|
||||
"gui.menuBar.caturdayMode",
|
||||
"gui.menuBar.downloadToComputer",
|
||||
"gui.menuBar.edit",
|
||||
"gui.menuBar.file",
|
||||
"gui.menuBar.isShared",
|
||||
"gui.menuBar.language",
|
||||
"gui.menuBar.modeMenu",
|
||||
"gui.menuBar.new",
|
||||
"gui.menuBar.normalMode",
|
||||
"gui.menuBar.remix",
|
||||
"gui.menuBar.restore",
|
||||
"gui.menuBar.restoreCostume",
|
||||
"gui.menuBar.restoreSound",
|
||||
"gui.menuBar.restoreSprite",
|
||||
"gui.menuBar.saveAsCopy",
|
||||
"gui.menuBar.saveNow",
|
||||
"gui.menuBar.saveNowLink",
|
||||
"gui.menuBar.seeProjectPage",
|
||||
"gui.menuBar.settings",
|
||||
"gui.menuBar.share",
|
||||
"gui.menuBar.turboModeOff",
|
||||
"gui.menuBar.turboModeOn",
|
||||
"gui.menuBar.tutorialsLibrary",
|
||||
"gui.modal.back",
|
||||
"gui.modal.help",
|
||||
"gui.monitor.contextMenu.default",
|
||||
"gui.monitor.contextMenu.export",
|
||||
"gui.monitor.contextMenu.hide",
|
||||
"gui.monitor.contextMenu.import",
|
||||
"gui.monitor.contextMenu.large",
|
||||
"gui.monitor.contextMenu.slider",
|
||||
"gui.monitor.contextMenu.sliderRange",
|
||||
"gui.monitor.listMonitor.empty",
|
||||
"gui.monitor.listMonitor.listLength",
|
||||
"gui.monitors.importListColumnPrompt",
|
||||
"gui.opcodeLabels.answer",
|
||||
"gui.opcodeLabels.backdropname",
|
||||
"gui.opcodeLabels.backdropnumber",
|
||||
"gui.opcodeLabels.costumename",
|
||||
"gui.opcodeLabels.costumenumber",
|
||||
"gui.opcodeLabels.date",
|
||||
"gui.opcodeLabels.dayofweek",
|
||||
"gui.opcodeLabels.direction",
|
||||
"gui.opcodeLabels.hour",
|
||||
"gui.opcodeLabels.loudness",
|
||||
"gui.opcodeLabels.minute",
|
||||
"gui.opcodeLabels.month",
|
||||
"gui.opcodeLabels.second",
|
||||
"gui.opcodeLabels.size",
|
||||
"gui.opcodeLabels.tempo",
|
||||
"gui.opcodeLabels.timer",
|
||||
"gui.opcodeLabels.username",
|
||||
"gui.opcodeLabels.volume",
|
||||
"gui.opcodeLabels.xposition",
|
||||
"gui.opcodeLabels.year",
|
||||
"gui.opcodeLabels.yposition",
|
||||
"gui.playButton.play",
|
||||
"gui.playButton.stop",
|
||||
"gui.playbackStep.loadingMsg",
|
||||
"gui.playbackStep.playMsg",
|
||||
"gui.playbackStep.reRecordMsg",
|
||||
"gui.playbackStep.saveMsg",
|
||||
"gui.playbackStep.stopMsg",
|
||||
"gui.prompt.cancel",
|
||||
"gui.prompt.ok",
|
||||
"gui.recordModal.title",
|
||||
"gui.recordingStep.alertMsg",
|
||||
"gui.recordingStep.beginRecord",
|
||||
"gui.recordingStep.permission",
|
||||
"gui.recordingStep.record",
|
||||
"gui.recordingStep.stop",
|
||||
"gui.sharedMessages.backdrop",
|
||||
"gui.sharedMessages.costume",
|
||||
"gui.sharedMessages.loadFromComputerTitle",
|
||||
"gui.sharedMessages.pop",
|
||||
"gui.sharedMessages.replaceProjectWarning",
|
||||
"gui.sharedMessages.sprite",
|
||||
"gui.sliderModal.max",
|
||||
"gui.sliderModal.min",
|
||||
"gui.sliderModal.title",
|
||||
"gui.sliderPrompt.cancel",
|
||||
"gui.sliderPrompt.ok",
|
||||
"gui.soundEditor.copy",
|
||||
"gui.soundEditor.copyToNew",
|
||||
"gui.soundEditor.delete",
|
||||
"gui.soundEditor.echo",
|
||||
"gui.soundEditor.fadeIn",
|
||||
"gui.soundEditor.fadeOut",
|
||||
"gui.soundEditor.faster",
|
||||
"gui.soundEditor.louder",
|
||||
"gui.soundEditor.mute",
|
||||
"gui.soundEditor.paste",
|
||||
"gui.soundEditor.play",
|
||||
"gui.soundEditor.redo",
|
||||
"gui.soundEditor.reverse",
|
||||
"gui.soundEditor.robot",
|
||||
"gui.soundEditor.save",
|
||||
"gui.soundEditor.slower",
|
||||
"gui.soundEditor.softer",
|
||||
"gui.soundEditor.sound",
|
||||
"gui.soundEditor.stop",
|
||||
"gui.soundEditor.undo",
|
||||
"gui.soundLibrary.chooseASound",
|
||||
"gui.soundTab.addSoundFromLibrary",
|
||||
"gui.soundTab.fileUploadSound",
|
||||
"gui.soundTab.recordSound",
|
||||
"gui.soundTab.surpriseSound",
|
||||
"gui.spriteLibrary.chooseASprite",
|
||||
"gui.spriteSelector.addBackdropFromLibrary",
|
||||
"gui.spriteSelector.addSpriteFromFile",
|
||||
"gui.spriteSelector.addSpriteFromLibrary",
|
||||
"gui.spriteSelector.addSpriteFromPaint",
|
||||
"gui.spriteSelector.addSpriteFromSurprise",
|
||||
"gui.spriteSelectorItem.contextMenuDelete",
|
||||
"gui.spriteSelectorItem.contextMenuDuplicate",
|
||||
"gui.spriteSelectorItem.contextMenuExport",
|
||||
"gui.stageHeader.fullscreenControl",
|
||||
"gui.stageHeader.stageSizeFull",
|
||||
"gui.stageHeader.stageSizeLarge",
|
||||
"gui.stageHeader.stageSizeSmall",
|
||||
"gui.stageHeader.stageSizeUnFull",
|
||||
"gui.stageSelector.addBackdropFromFile",
|
||||
"gui.stageSelector.addBackdropFromPaint",
|
||||
"gui.stageSelector.addBackdropFromSurprise",
|
||||
"gui.stageSelector.backdrops",
|
||||
"gui.stageSelector.stage",
|
||||
"gui.telemetryOptIn.body1",
|
||||
"gui.telemetryOptIn.body2",
|
||||
"gui.telemetryOptIn.buttonClose",
|
||||
"gui.telemetryOptIn.label",
|
||||
"gui.telemetryOptIn.optInText",
|
||||
"gui.telemetryOptIn.optInTooltip",
|
||||
"gui.telemetryOptIn.optOutText",
|
||||
"gui.telemetryOptIn.optOutTooltip",
|
||||
"gui.telemetryOptIn.privacyPolicyLink",
|
||||
"gui.telemetryOptIn.settingWasUpdated",
|
||||
"gui.tipsLibrary.tutorials",
|
||||
"gui.turboMode.active",
|
||||
"gui.unsupportedBrowser.label",
|
||||
"gui.webglModal.webgllink",
|
||||
"makeymakey.downArrow",
|
||||
"makeymakey.downArrowShort",
|
||||
"makeymakey.leftArrow",
|
||||
"makeymakey.leftArrowShort",
|
||||
"makeymakey.rightArrow",
|
||||
"makeymakey.rightArrowShort",
|
||||
"makeymakey.spaceKey",
|
||||
"makeymakey.upArrow",
|
||||
"makeymakey.upArrowShort",
|
||||
"makeymakey.whenKeyPressed",
|
||||
"makeymakey.whenKeysPressedInOrder",
|
||||
"microbit.buttonsMenu.any",
|
||||
"microbit.clearDisplay",
|
||||
"microbit.defaultTextToDisplay",
|
||||
"microbit.displaySymbol",
|
||||
"microbit.displayText",
|
||||
"microbit.gesturesMenu.jumped",
|
||||
"microbit.gesturesMenu.moved",
|
||||
"microbit.gesturesMenu.shaken",
|
||||
"microbit.isButtonPressed",
|
||||
"microbit.isTilted",
|
||||
"microbit.pinStateMenu.off",
|
||||
"microbit.pinStateMenu.on",
|
||||
"microbit.tiltAngle",
|
||||
"microbit.tiltDirectionMenu.any",
|
||||
"microbit.tiltDirectionMenu.back",
|
||||
"microbit.tiltDirectionMenu.front",
|
||||
"microbit.tiltDirectionMenu.left",
|
||||
"microbit.tiltDirectionMenu.right",
|
||||
"microbit.whenButtonPressed",
|
||||
"microbit.whenGesture",
|
||||
"microbit.whenPinConnected",
|
||||
"microbit.whenTilted",
|
||||
"music.categoryName",
|
||||
"music.changeTempo",
|
||||
"music.drumBass",
|
||||
"music.drumBongo",
|
||||
"music.drumCabasa",
|
||||
"music.drumClaves",
|
||||
"music.drumClosedHiHat",
|
||||
"music.drumConga",
|
||||
"music.drumCowbell",
|
||||
"music.drumCrashCymbal",
|
||||
"music.drumCuica",
|
||||
"music.drumGuiro",
|
||||
"music.drumHandClap",
|
||||
"music.drumOpenHiHat",
|
||||
"music.drumSideStick",
|
||||
"music.drumSnare",
|
||||
"music.drumTambourine",
|
||||
"music.drumTriangle",
|
||||
"music.drumVibraslap",
|
||||
"music.drumWoodBlock",
|
||||
"music.getTempo",
|
||||
"music.instrumentBass",
|
||||
"music.instrumentBassoon",
|
||||
"music.instrumentCello",
|
||||
"music.instrumentChoir",
|
||||
"music.instrumentClarinet",
|
||||
"music.instrumentElectricGuitar",
|
||||
"music.instrumentElectricPiano",
|
||||
"music.instrumentFlute",
|
||||
"music.instrumentGuitar",
|
||||
"music.instrumentMarimba",
|
||||
"music.instrumentMusicBox",
|
||||
"music.instrumentOrgan",
|
||||
"music.instrumentPiano",
|
||||
"music.instrumentPizzicato",
|
||||
"music.instrumentSaxophone",
|
||||
"music.instrumentSteelDrum",
|
||||
"music.instrumentSynthLead",
|
||||
"music.instrumentSynthPad",
|
||||
"music.instrumentTrombone",
|
||||
"music.instrumentVibraphone",
|
||||
"music.instrumentWoodenFlute",
|
||||
"music.midiPlayDrumForBeats",
|
||||
"music.midiSetInstrument",
|
||||
"music.playDrumForBeats",
|
||||
"music.playNoteForBeats",
|
||||
"music.restForBeats",
|
||||
"music.setInstrument",
|
||||
"music.setTempo",
|
||||
"paint.brushMode.brush",
|
||||
"paint.colorPicker.swap",
|
||||
"paint.eraserMode.eraser",
|
||||
"paint.fillMode.fill",
|
||||
"paint.lineMode.line",
|
||||
"paint.modeTools.brushSize",
|
||||
"paint.modeTools.copy",
|
||||
"paint.modeTools.curved",
|
||||
"paint.modeTools.delete",
|
||||
"paint.modeTools.eraserSize",
|
||||
"paint.modeTools.filled",
|
||||
"paint.modeTools.flipHorizontal",
|
||||
"paint.modeTools.flipVertical",
|
||||
"paint.modeTools.outlined",
|
||||
"paint.modeTools.paste",
|
||||
"paint.modeTools.pointed",
|
||||
"paint.modeTools.thickness",
|
||||
"paint.ovalMode.oval",
|
||||
"paint.paintEditor.back",
|
||||
"paint.paintEditor.backward",
|
||||
"paint.paintEditor.bitmap",
|
||||
"paint.paintEditor.brightness",
|
||||
"paint.paintEditor.costume",
|
||||
"paint.paintEditor.fill",
|
||||
"paint.paintEditor.forward",
|
||||
"paint.paintEditor.front",
|
||||
"paint.paintEditor.group",
|
||||
"paint.paintEditor.hue",
|
||||
"paint.paintEditor.more",
|
||||
"paint.paintEditor.redo",
|
||||
"paint.paintEditor.saturation",
|
||||
"paint.paintEditor.stroke",
|
||||
"paint.paintEditor.undo",
|
||||
"paint.paintEditor.ungroup",
|
||||
"paint.paintEditor.vector",
|
||||
"paint.rectMode.rect",
|
||||
"paint.reshapeMode.reshape",
|
||||
"paint.roundedRectMode.roundedRect",
|
||||
"paint.selectMode.select",
|
||||
"paint.textMode.text",
|
||||
"pen.categoryName",
|
||||
"pen.changeColorParam",
|
||||
"pen.changeHue",
|
||||
"pen.changeShade",
|
||||
"pen.changeSize",
|
||||
"pen.clear",
|
||||
"pen.colorMenu.brightness",
|
||||
"pen.colorMenu.color",
|
||||
"pen.colorMenu.saturation",
|
||||
"pen.colorMenu.transparency",
|
||||
"pen.penDown",
|
||||
"pen.penUp",
|
||||
"pen.setColor",
|
||||
"pen.setColorParam",
|
||||
"pen.setHue",
|
||||
"pen.setShade",
|
||||
"pen.setSize",
|
||||
"pen.stamp",
|
||||
"speech.defaultWhenIHearValue",
|
||||
"speech.extensionName",
|
||||
"speech.listenAndWait",
|
||||
"speech.speechReporter",
|
||||
"speech.whenIHear",
|
||||
"text2speech.alto",
|
||||
"text2speech.categoryName",
|
||||
"text2speech.defaultTextToSpeak",
|
||||
"text2speech.giant",
|
||||
"text2speech.kitten",
|
||||
"text2speech.setLanguageBlock",
|
||||
"text2speech.setVoiceBlock",
|
||||
"text2speech.speakAndWaitBlock",
|
||||
"text2speech.squeak",
|
||||
"text2speech.tenor",
|
||||
"translate.categoryName",
|
||||
"translate.defaultTextToTranslate",
|
||||
"translate.translateBlock",
|
||||
"translate.viewerLanguage",
|
||||
"tw.accent.blue",
|
||||
"tw.accent.purple",
|
||||
"tw.accent.rainbow",
|
||||
"tw.accent.red",
|
||||
"tw.alerts.creatingRestorePoint",
|
||||
"tw.alerts.lostPeripheralConnection",
|
||||
"tw.alerts.restorePointError",
|
||||
"tw.alerts.restorePointSuccess",
|
||||
"tw.alerts.savedToDisk",
|
||||
"tw.backpack.rename",
|
||||
"tw.blockColors.custom",
|
||||
"tw.blockColors.dark",
|
||||
"tw.blockColors.highContrast",
|
||||
"tw.blockColors.three",
|
||||
"tw.blocks.PROCEDURES_DOCS",
|
||||
"tw.blocks.PROCEDURES_RETURN",
|
||||
"tw.blocks.PROCEDURES_TO_REPORTER",
|
||||
"tw.blocks.PROCEDURES_TO_STATEMENT",
|
||||
"tw.blocks.addons",
|
||||
"tw.blocks.buttonIsDown",
|
||||
"tw.blocks.lastKeyPressed",
|
||||
"tw.blocks.mouseButton.middle",
|
||||
"tw.blocks.mouseButton.primary",
|
||||
"tw.blocks.mouseButton.secondary",
|
||||
"tw.blocks.openDocs",
|
||||
"tw.browserModal.desc",
|
||||
"tw.cantUseCloud",
|
||||
"tw.changeUsername.cannotChangeWhileRunning",
|
||||
"tw.clipboard.danger",
|
||||
"tw.clipboard.permission",
|
||||
"tw.clipboard.title",
|
||||
"tw.cloudProvider",
|
||||
"tw.cloudServers",
|
||||
"tw.code",
|
||||
"tw.confirmIncompatibleExtension",
|
||||
"tw.createdBy",
|
||||
"tw.customCloudServer",
|
||||
"tw.customExtension.description",
|
||||
"tw.customExtension.name",
|
||||
"tw.customExtensionModal.file",
|
||||
"tw.customExtensionModal.load",
|
||||
"tw.customExtensionModal.promptFile",
|
||||
"tw.customExtensionModal.promptText",
|
||||
"tw.customExtensionModal.promptURL",
|
||||
"tw.customExtensionModal.text",
|
||||
"tw.customExtensionModal.title",
|
||||
"tw.customExtensionModal.trusted",
|
||||
"tw.customExtensionModal.unsandboxed",
|
||||
"tw.customExtensionModal.unsandboxedWarning1",
|
||||
"tw.customExtensionModal.unsandboxedWarning2",
|
||||
"tw.customExtensionModal.untrusted",
|
||||
"tw.customExtensionModal.url",
|
||||
"tw.customReporters.description",
|
||||
"tw.customReporters.name",
|
||||
"tw.darkMode",
|
||||
"tw.desktopCloud",
|
||||
"tw.documentation",
|
||||
"tw.embed.persistent",
|
||||
"tw.embed.risks",
|
||||
"tw.embed.title1",
|
||||
"tw.embed.title2",
|
||||
"tw.extensionGallery.error",
|
||||
"tw.extensionGallery.loading",
|
||||
"tw.extensionGallery.more",
|
||||
"tw.extensionGallery.name",
|
||||
"tw.favorite",
|
||||
"tw.featuredProjectsStudio",
|
||||
"tw.feedback",
|
||||
"tw.feedbackButton",
|
||||
"tw.fetch.title",
|
||||
"tw.fileInput.none",
|
||||
"tw.fileInput.selected",
|
||||
"tw.fonts.add",
|
||||
"tw.fonts.custom.file",
|
||||
"tw.fonts.custom.name",
|
||||
"tw.fonts.custom1",
|
||||
"tw.fonts.custom2",
|
||||
"tw.fonts.delete",
|
||||
"tw.fonts.fallback",
|
||||
"tw.fonts.list",
|
||||
"tw.fonts.none",
|
||||
"tw.fonts.readError",
|
||||
"tw.fonts.system",
|
||||
"tw.fonts.system.name",
|
||||
"tw.fonts.system1",
|
||||
"tw.fonts.system2",
|
||||
"tw.fonts.title",
|
||||
"tw.footer.credits",
|
||||
"tw.footer.disclaimer",
|
||||
"tw.footer.documentation",
|
||||
"tw.footer.donate",
|
||||
"tw.footer.embed",
|
||||
"tw.footer.parameters",
|
||||
"tw.footer.scratchDisclaimer",
|
||||
"tw.fps",
|
||||
"tw.geolocate.permission",
|
||||
"tw.geolocate.title",
|
||||
"tw.gui.crashMessage.description",
|
||||
"tw.gui.defaultProjectTitle",
|
||||
"tw.guiDefaultTitle",
|
||||
"tw.home.credit",
|
||||
"tw.home.description",
|
||||
"tw.home.instructions",
|
||||
"tw.input.tooltip",
|
||||
"tw.interpolationEnabled",
|
||||
"tw.invalidParameters.clones",
|
||||
"tw.invalidParameters.fps",
|
||||
"tw.invalidProject.error",
|
||||
"tw.invalidProject.options",
|
||||
"tw.invalidProject.reportIt",
|
||||
"tw.invalidProject.restorePoints",
|
||||
"tw.invalidProject.title",
|
||||
"tw.invalidProject.validationError",
|
||||
"tw.lightMode",
|
||||
"tw.loadExtension.embedded",
|
||||
"tw.loadExtension.sandboxed",
|
||||
"tw.loadExtension.unsandboxed",
|
||||
"tw.loadExtension.unsandboxedWarning",
|
||||
"tw.loadExtension.url",
|
||||
"tw.loader.downloadingAssets",
|
||||
"tw.loader.loadingAssets",
|
||||
"tw.loader.projectData",
|
||||
"tw.lockdownMode",
|
||||
"tw.lockdownMode2",
|
||||
"tw.menuBar.60off",
|
||||
"tw.menuBar.60on",
|
||||
"tw.menuBar.accent",
|
||||
"tw.menuBar.addons",
|
||||
"tw.menuBar.advanced",
|
||||
"tw.menuBar.blockColors",
|
||||
"tw.menuBar.changeUsername",
|
||||
"tw.menuBar.cloudOff",
|
||||
"tw.menuBar.cloudOn",
|
||||
"tw.menuBar.cloudUnavailable",
|
||||
"tw.menuBar.cloudUnavailableAlert",
|
||||
"tw.menuBar.compileError",
|
||||
"tw.menuBar.desktopSettings",
|
||||
"tw.menuBar.moreSettings",
|
||||
"tw.menuBar.newFramerate",
|
||||
"tw.menuBar.newWindow",
|
||||
"tw.menuBar.package",
|
||||
"tw.menuBar.reportError1",
|
||||
"tw.menuBar.reportError2",
|
||||
"tw.menuBar.restorePoints",
|
||||
"tw.menuBar.saveAs",
|
||||
"tw.menuBar.seeInside",
|
||||
"tw.mono",
|
||||
"tw.moreCloud",
|
||||
"tw.notify.permission",
|
||||
"tw.notify.title",
|
||||
"tw.oldDownload",
|
||||
"tw.opcode.2000",
|
||||
"tw.opcode.mousedown",
|
||||
"tw.opcode.mousex",
|
||||
"tw.opcode.mousey",
|
||||
"tw.openAdvanced",
|
||||
"tw.openWindow.dangerous",
|
||||
"tw.openWindow.title",
|
||||
"tw.paint.alpha",
|
||||
"tw.paint.fonts.more",
|
||||
"tw.pen.stageSelected",
|
||||
"tw.privacy",
|
||||
"tw.recordAudio.permission",
|
||||
"tw.recordAudio.title",
|
||||
"tw.recordVideo.permission",
|
||||
"tw.recordVideo.title",
|
||||
"tw.redirect.dangerous",
|
||||
"tw.redirect.title",
|
||||
"tw.removedTrademarks",
|
||||
"tw.restorePoints.1minute",
|
||||
"tw.restorePoints.assets",
|
||||
"tw.restorePoints.confirmDelete",
|
||||
"tw.restorePoints.confirmDeleteAll",
|
||||
"tw.restorePoints.confirmLoad",
|
||||
"tw.restorePoints.deleteAll",
|
||||
"tw.restorePoints.description",
|
||||
"tw.restorePoints.empty",
|
||||
"tw.restorePoints.error",
|
||||
"tw.restorePoints.intervalOption",
|
||||
"tw.restorePoints.loading",
|
||||
"tw.restorePoints.minutes",
|
||||
"tw.restorePoints.never",
|
||||
"tw.restorePoints.off",
|
||||
"tw.restorePoints.size",
|
||||
"tw.restorePoints.size2",
|
||||
"tw.restorePoints.title",
|
||||
"tw.sample",
|
||||
"tw.saveAs",
|
||||
"tw.saveTo",
|
||||
"tw.scratchUnsafeCloud",
|
||||
"tw.securityManager.allow",
|
||||
"tw.securityManager.deny",
|
||||
"tw.securityManager.title",
|
||||
"tw.securityManager.trust",
|
||||
"tw.securityManager.why",
|
||||
"tw.settingsModal.customStageSize",
|
||||
"tw.settingsModal.customStageSizeHelp",
|
||||
"tw.settingsModal.dangerZone",
|
||||
"tw.settingsModal.disableCompiler",
|
||||
"tw.settingsModal.disableCompilerHelp",
|
||||
"tw.settingsModal.featured",
|
||||
"tw.settingsModal.fps",
|
||||
"tw.settingsModal.fpsHelp",
|
||||
"tw.settingsModal.fpsHelp.customFramerate",
|
||||
"tw.settingsModal.help",
|
||||
"tw.settingsModal.highQualityPen",
|
||||
"tw.settingsModal.highQualityPenHelp",
|
||||
"tw.settingsModal.infiniteClones",
|
||||
"tw.settingsModal.infiniteClonesHelp",
|
||||
"tw.settingsModal.interpolation",
|
||||
"tw.settingsModal.interpolationHelp",
|
||||
"tw.settingsModal.largeStageWarning",
|
||||
"tw.settingsModal.removeFencing",
|
||||
"tw.settingsModal.removeFencingHelp",
|
||||
"tw.settingsModal.removeLimits",
|
||||
"tw.settingsModal.removeMiscLimits",
|
||||
"tw.settingsModal.removeMiscLimitsHelp",
|
||||
"tw.settingsModal.storeProjectOptions",
|
||||
"tw.settingsModal.storeProjectOptionsHelp",
|
||||
"tw.settingsModal.title",
|
||||
"tw.settingsModal.warpTimer",
|
||||
"tw.settingsModal.warpTimerHelp",
|
||||
"tw.soundEditorNotSupported",
|
||||
"tw.spriteSelectorItem.rename",
|
||||
"tw.stageHeader.full",
|
||||
"tw.stereo",
|
||||
"tw.stereoAlert",
|
||||
"tw.studioview.authorAttribution",
|
||||
"tw.studioview.error",
|
||||
"tw.studioview.hoverText",
|
||||
"tw.tooLarge",
|
||||
"tw.twExtension.description",
|
||||
"tw.twExtension.name",
|
||||
"tw.unfavorite",
|
||||
"tw.unknownPlatform.1",
|
||||
"tw.unknownPlatform.2",
|
||||
"tw.unknownPlatform.continue",
|
||||
"tw.unknownPlatform.title",
|
||||
"tw.unshared.2",
|
||||
"tw.unshared.bug",
|
||||
"tw.unshared.cache",
|
||||
"tw.unshared2.1",
|
||||
"tw.usernameModal.help",
|
||||
"tw.usernameModal.help2",
|
||||
"tw.usernameModal.mustChange",
|
||||
"tw.usernameModal.mustChange.resetIt",
|
||||
"tw.usernameModal.new",
|
||||
"tw.usernameModal.reset",
|
||||
"tw.usernameModal.title",
|
||||
"tw.usesCloudVariables",
|
||||
"tw.usesCloudVariables2",
|
||||
"tw.usesCloudVariables2.change",
|
||||
"tw.viewFeaturedProjects",
|
||||
"tw.viewOnScratch",
|
||||
"tw.webglModal.description",
|
||||
"videoSensing.categoryName",
|
||||
"videoSensing.direction",
|
||||
"videoSensing.motion",
|
||||
"videoSensing.off",
|
||||
"videoSensing.on",
|
||||
"videoSensing.onFlipped",
|
||||
"videoSensing.setVideoTransparency",
|
||||
"videoSensing.sprite",
|
||||
"videoSensing.stage",
|
||||
"videoSensing.videoOn",
|
||||
"videoSensing.videoToggle",
|
||||
"videoSensing.whenMotionGreaterThan",
|
||||
"wedo2.getDistance",
|
||||
"wedo2.getTiltAngle",
|
||||
"wedo2.isTilted",
|
||||
"wedo2.motorDirection.backward",
|
||||
"wedo2.motorDirection.forward",
|
||||
"wedo2.motorDirection.reverse",
|
||||
"wedo2.motorId.a",
|
||||
"wedo2.motorId.all",
|
||||
"wedo2.motorId.b",
|
||||
"wedo2.motorId.default",
|
||||
"wedo2.motorOff",
|
||||
"wedo2.motorOn",
|
||||
"wedo2.motorOnFor",
|
||||
"wedo2.playNoteFor",
|
||||
"wedo2.setLightHue",
|
||||
"wedo2.setMotorDirection",
|
||||
"wedo2.startMotorPower",
|
||||
"wedo2.tiltDirection.any",
|
||||
"wedo2.tiltDirection.down",
|
||||
"wedo2.tiltDirection.left",
|
||||
"wedo2.tiltDirection.right",
|
||||
"wedo2.tiltDirection.up",
|
||||
"wedo2.whenDistance",
|
||||
"wedo2.whenTilted"
|
||||
]
|
||||
19
scratch-l10n/scripts/tw-locales.js
Normal file
19
scratch-l10n/scripts/tw-locales.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import vanillaLocales, {localeMap} from '../src/supported-locales';
|
||||
|
||||
const SKIP_LOCALES = [
|
||||
// For es-419, we just use normal Spanish
|
||||
'es-419'
|
||||
];
|
||||
|
||||
/** @type {Record<string, {name: string}>} */
|
||||
const supportedLocales = {};
|
||||
for (const locale of Object.keys(vanillaLocales).sort()) {
|
||||
if (!SKIP_LOCALES.includes(locale)) {
|
||||
supportedLocales[locale] = JSON.parse(JSON.stringify(vanillaLocales[locale]));
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
supportedLocales,
|
||||
localeMap as scratchToTransifex
|
||||
};
|
||||
351
scratch-l10n/scripts/tw-pull.js
Normal file
351
scratch-l10n/scripts/tw-pull.js
Normal file
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env babel-node
|
||||
|
||||
import pathUtil from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import {txPull, txGetResourceStatistics} from '../lib/transifex';
|
||||
import {supportedLocales, scratchToTransifex} from './tw-locales';
|
||||
import {batchMap} from '../lib/batch.js';
|
||||
|
||||
/* eslint-disable valid-jsdoc */
|
||||
|
||||
const PROJECT = 'turbowarp';
|
||||
const CONCURRENCY_LIMIT = 36;
|
||||
const SOURCE_LOCALE = 'en';
|
||||
|
||||
/**
|
||||
* Not sure how to do this in JSDoc
|
||||
* @template T
|
||||
* @typedef {Record<string, T>} NestedRecord<T>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalizes messages in the following ways by converting objects with context to just strings,
|
||||
* and ensures that the order of keys is consistent.
|
||||
* @param {NestedRecord<string | {string: string}>} messages
|
||||
* @returns {NestedRecord<string>}
|
||||
*/
|
||||
const normalizeMessages = messages => {
|
||||
const result = {};
|
||||
for (const id of Object.keys(messages).sort()) {
|
||||
const string = messages[id];
|
||||
if (typeof string === 'string') {
|
||||
// Don't touch normal strings.
|
||||
result[id] = string;
|
||||
} else if (typeof string.string === 'string') {
|
||||
// Convert structured strings with context to normal strings.
|
||||
result[id] = string.string;
|
||||
} else {
|
||||
// Recurse into nested message objects.
|
||||
result[id] = normalizeMessages(string);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {NestedRecord<string>} localeMessages
|
||||
* @param {NestedRecord<string>} sourceMessages
|
||||
* @returns {NestedRecord<string>}
|
||||
*/
|
||||
const removeRedundantMessages = (localeMessages, sourceMessages) => {
|
||||
const result = {};
|
||||
for (const [messageId, messageContent] of Object.entries(localeMessages)) {
|
||||
const string = messageContent;
|
||||
const sourceString = sourceMessages[messageId];
|
||||
if (typeof string === 'string') {
|
||||
// Copy strings that do not exactly match their English counterpart.
|
||||
if (string !== sourceString) {
|
||||
result[messageId] = string;
|
||||
}
|
||||
} else {
|
||||
// Recurse into nested objects.
|
||||
const nested = removeRedundantMessages(string, sourceString);
|
||||
if (Object.keys(nested).length !== 0) {
|
||||
result[messageId] = nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string[]} locales
|
||||
*/
|
||||
const createProgressPrinter = locales => {
|
||||
const RESET = `\u001b[0m`;
|
||||
const BOLD = `\u001b[1m`;
|
||||
const GRAY = '\u001b[90m';
|
||||
const BLUE = `\u001b[34m`;
|
||||
const GREEN = '\u001b[32m';
|
||||
const CLEAR = '\u001b[0k';
|
||||
|
||||
const NOT_STARTED = 0;
|
||||
const STARTED = 1;
|
||||
const FINISHED = 2;
|
||||
|
||||
let ended = false;
|
||||
const states = {};
|
||||
for (const locale of locales) {
|
||||
states[locale] = NOT_STARTED;
|
||||
}
|
||||
|
||||
const print = () => {
|
||||
if (ended) {
|
||||
return;
|
||||
}
|
||||
const items = Object.entries(states).map(([locale, state]) => {
|
||||
let color = '??';
|
||||
if (state === NOT_STARTED) color = GRAY;
|
||||
if (state === STARTED) color = BLUE;
|
||||
if (state === FINISHED) color = BOLD + GREEN;
|
||||
return `${color}${locale}${RESET}`;
|
||||
});
|
||||
const total = locales.length;
|
||||
const totalFinished = Object.values(states).filter(i => i === FINISHED).length;
|
||||
process.stdout.write(`\r${CLEAR}${items.join(' ')}${RESET} ${totalFinished}/${total}`);
|
||||
};
|
||||
|
||||
const startedItem = locale => {
|
||||
states[locale] = STARTED;
|
||||
print();
|
||||
};
|
||||
|
||||
const finishedItem = locale => {
|
||||
states[locale] = FINISHED;
|
||||
print();
|
||||
};
|
||||
|
||||
const end = () => {
|
||||
ended = true;
|
||||
// Move cursor to own line.
|
||||
console.log('');
|
||||
};
|
||||
|
||||
print();
|
||||
return {
|
||||
startedItem,
|
||||
finishedItem,
|
||||
end
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} resource Name of Transifex resource
|
||||
* @param {number} requiredCompletion Number from 0-1 indicating what % of strings must be translated.
|
||||
* Locales that do not meet this threshold will not be included in the result.
|
||||
* @returns {Promise<Record<string, Record<string, string>>} Does not include source messages.
|
||||
*/
|
||||
const pullResource = async (resource, requiredCompletion) => {
|
||||
console.log(`Pulling ${resource}...`);
|
||||
|
||||
const transifexStatistics = await txGetResourceStatistics(PROJECT, resource);
|
||||
const totalStrings = transifexStatistics[SOURCE_LOCALE];
|
||||
const threshold = Math.max(1, Math.round(totalStrings * requiredCompletion));
|
||||
const localesToFetch = Object.keys(supportedLocales).filter(locale => {
|
||||
const transifexLocale = scratchToTransifex[locale] || locale;
|
||||
const translatedStrings = transifexStatistics[transifexLocale];
|
||||
if (typeof translatedStrings !== 'number') {
|
||||
throw new Error(`Missing locale ${supportedLocales[locale].name} (${locale}) in ${resource}`);
|
||||
}
|
||||
return translatedStrings >= threshold;
|
||||
});
|
||||
|
||||
const progress = createProgressPrinter(localesToFetch);
|
||||
|
||||
const values = await batchMap(localesToFetch, CONCURRENCY_LIMIT, async locale => {
|
||||
progress.startedItem(locale);
|
||||
try {
|
||||
const messages = await txPull(PROJECT, resource, scratchToTransifex[locale] || locale);
|
||||
progress.finishedItem(locale);
|
||||
return {
|
||||
scratchLocale: locale,
|
||||
messages: normalizeMessages(messages)
|
||||
};
|
||||
} catch (error) {
|
||||
progress.end();
|
||||
// Transifex's error messages sometimes lack enough detail, so we will include
|
||||
// some extra information.
|
||||
console.error(`Could not fetch messages for locale: ${locale}`);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const sourceMessages = values.find(i => i.scratchLocale === SOURCE_LOCALE).messages;
|
||||
const result = {};
|
||||
for (const pulled of values) {
|
||||
const slimmedMessages = removeRedundantMessages(pulled.messages, sourceMessages);
|
||||
if (Object.keys(slimmedMessages).length > 0) {
|
||||
result[pulled.scratchLocale.toLowerCase()] = slimmedMessages;
|
||||
}
|
||||
}
|
||||
|
||||
progress.end();
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isDirectorySync = path => {
|
||||
try {
|
||||
const stat = fs.statSync(path);
|
||||
return stat.isDirectory();
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {NestedRecord<string, string>} messages
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
const generateSmallestLocaleNamesMap = messages => {
|
||||
const lowercaseSupportedLocales = {};
|
||||
for (const [locale, value] of Object.entries(supportedLocales)) {
|
||||
lowercaseSupportedLocales[locale.toLowerCase()] = value;
|
||||
}
|
||||
const result = {
|
||||
[SOURCE_LOCALE]: supportedLocales[SOURCE_LOCALE].name
|
||||
};
|
||||
for (const locale of Object.keys(messages)) {
|
||||
result[locale] = lowercaseSupportedLocales[locale].name;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const pullGui = async () => {
|
||||
const scratchGui = pathUtil.join(__dirname, '../../scratch-gui');
|
||||
if (!isDirectorySync(scratchGui)) {
|
||||
console.log('Skipping editor; could not find scratch-gui.');
|
||||
return;
|
||||
}
|
||||
|
||||
const guiTranslationsFile = pathUtil.join(scratchGui, 'src/lib/tw-translations/generated-translations.json');
|
||||
// These translations build upon scratch-l10n, so the threshold should be 0.
|
||||
const guiTranslations = await pullResource('guijson', 0);
|
||||
fs.writeFileSync(guiTranslationsFile, JSON.stringify(guiTranslations, null, 4));
|
||||
|
||||
const addonsTranslationsFile = pathUtil.join(scratchGui, 'src/addons/settings/translations.json');
|
||||
const addonsTranslations = await pullResource('addonsjson', 0.7);
|
||||
fs.writeFileSync(addonsTranslationsFile, JSON.stringify(addonsTranslations, null, 4));
|
||||
};
|
||||
|
||||
const pullPackager = async () => {
|
||||
const packager = pathUtil.join(__dirname, '../../packager');
|
||||
if (!isDirectorySync(packager)) {
|
||||
console.log('Skipping packager; could not find packager.');
|
||||
return;
|
||||
}
|
||||
|
||||
const translations = await pullResource('packagerjson', 0.5);
|
||||
|
||||
// Delete old JSON files. Some languages that were previously supported might no longer be.
|
||||
const localesDirectory = pathUtil.join(packager, 'src', 'locales');
|
||||
for (const name of fs.readdirSync(localesDirectory)) {
|
||||
if (name.endsWith('.json') && name !== 'en.json') {
|
||||
fs.unlinkSync(pathUtil.join(localesDirectory, name));
|
||||
}
|
||||
}
|
||||
|
||||
// Write the individual JSON files
|
||||
for (const [locale, messages] of Object.entries(translations)) {
|
||||
const path = pathUtil.join(localesDirectory, `${locale}.json`);
|
||||
fs.writeFileSync(path, JSON.stringify(messages, null, 4));
|
||||
}
|
||||
|
||||
// Write the index.js manifest
|
||||
const index = pathUtil.join(localesDirectory, 'index.js');
|
||||
const oldContent = fs.readFileSync(index, 'utf-8');
|
||||
const newContent = oldContent.replace(/\/\*===\*\/[\s\S]+\/\*===\*\//m, `/*===*/\n${
|
||||
Object.keys(translations)
|
||||
.map(i => ` ${JSON.stringify(i)}: () => require(${JSON.stringify(`./${i}.json`)})`)
|
||||
.join(',\n')
|
||||
},\n /*===*/`);
|
||||
fs.writeFileSync(index, newContent);
|
||||
|
||||
// Write locale-names.json
|
||||
const localeNames = generateSmallestLocaleNamesMap(translations);
|
||||
fs.writeFileSync(pathUtil.join(localesDirectory, 'locale-names.json'), JSON.stringify(localeNames, null, 4));
|
||||
};
|
||||
|
||||
const pullDesktop = async () => {
|
||||
const desktop = pathUtil.join(__dirname, '../../turbowarp-desktop');
|
||||
if (!isDirectorySync(desktop)) {
|
||||
console.log('Skipping desktop; could not find turbowarp-desktop.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop app translations
|
||||
const desktopTranslations = await pullResource('desktopnewjson', 0.5);
|
||||
fs.writeFileSync(
|
||||
pathUtil.join(desktop, 'src-main/l10n/generated-translations.json'),
|
||||
JSON.stringify(desktopTranslations, null, 4)
|
||||
);
|
||||
|
||||
// Website translations
|
||||
const webTranslations = await pullResource('desktopturbowarporg-redesign', 0.7);
|
||||
const localeNames = generateSmallestLocaleNamesMap(webTranslations);
|
||||
const indexHtml = pathUtil.join(desktop, 'docs', 'index.html');
|
||||
const oldContent = fs.readFileSync(indexHtml, 'utf-8');
|
||||
const newContent = oldContent
|
||||
.replace(
|
||||
/ *<!-- L10N_START -->[\s\S]*?<!-- L10N_END -->/gm,
|
||||
[
|
||||
'<!-- L10N_START -->',
|
||||
...Object.entries(webTranslations).map(([locale, data]) => (
|
||||
`<script type="generated-translations" data-locale="${locale}">${JSON.stringify(data)}</script>`
|
||||
)),
|
||||
'<!-- L10N_END -->'
|
||||
].map(line => ` ${line}`).join('\n')
|
||||
)
|
||||
.replace(
|
||||
/<script type="generated-locale-names">[\s\S]+?<\/script>/,
|
||||
`<script type="generated-locale-names">${JSON.stringify(localeNames)}</script>`
|
||||
);
|
||||
fs.writeFileSync(indexHtml, newContent);
|
||||
|
||||
const storeListings = await pullResource('store-listingsyaml', 1);
|
||||
fs.writeFileSync(
|
||||
pathUtil.join(desktop, 'store-listings/imported.json'),
|
||||
JSON.stringify(storeListings, null, 4)
|
||||
);
|
||||
};
|
||||
|
||||
const pullExtensions = async () => {
|
||||
const extensions = pathUtil.join(__dirname, '../../extensions');
|
||||
if (!isDirectorySync(extensions)) {
|
||||
console.log('Skipping extensions; could not find extensions.');
|
||||
return;
|
||||
}
|
||||
|
||||
const metadataTranslations = await pullResource('extension-metadata', 0);
|
||||
fs.writeFileSync(
|
||||
pathUtil.join(extensions, 'translations/extension-metadata.json'),
|
||||
JSON.stringify(metadataTranslations, null, 4)
|
||||
);
|
||||
|
||||
const runtimeTranslations = await pullResource('extensions', 0);
|
||||
fs.writeFileSync(
|
||||
pathUtil.join(extensions, 'translations/extension-runtime.json'),
|
||||
JSON.stringify(runtimeTranslations, null, 4)
|
||||
);
|
||||
};
|
||||
|
||||
const pullEverything = async () => {
|
||||
try {
|
||||
console.log('DOWNLOADING from Transifex...');
|
||||
await pullGui();
|
||||
await pullPackager();
|
||||
await pullDesktop();
|
||||
await pullExtensions();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
pullEverything();
|
||||
169
scratch-l10n/scripts/tw-push.js
Executable file
169
scratch-l10n/scripts/tw-push.js
Executable file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env babel-node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import pathUtil from 'node:path';
|
||||
import {txPush} from '../lib/transifex.js';
|
||||
|
||||
/* eslint-disable valid-jsdoc */
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isDirectorySync = (path) => {
|
||||
try {
|
||||
const stat = fs.statSync(path);
|
||||
return stat.isDirectory();
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} directory
|
||||
* @returns {string[]}
|
||||
*/
|
||||
const recursiveReadDirectory = (directory) => {
|
||||
const children = fs.readdirSync(directory);
|
||||
const result = [];
|
||||
for (const name of children) {
|
||||
const path = pathUtil.join(directory, name);
|
||||
if (isDirectorySync(path)) {
|
||||
const directoryChildren = recursiveReadDirectory(path);
|
||||
for (const childName of directoryChildren) {
|
||||
result.push(pathUtil.join(name, childName));
|
||||
}
|
||||
} else {
|
||||
result.push(name);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const scratchGui = pathUtil.join(__dirname, '..', '..', 'scratch-gui');
|
||||
const scratchGuiTranslations = pathUtil.join(scratchGui, 'translations');
|
||||
const scratchVm = pathUtil.join(__dirname, '..', '..', 'scratch-vm');
|
||||
if (!isDirectorySync(scratchGui)) throw new Error('Cannot find scratch-gui');
|
||||
if (!isDirectorySync(scratchGuiTranslations)) throw new Error('Cannot find scratch-gui translations');
|
||||
if (!isDirectorySync(scratchVm)) throw new Error('Cannot find scratch-vm');
|
||||
|
||||
/**
|
||||
* @typedef StructuredMessage
|
||||
* @property {string} string
|
||||
* @property {string} context
|
||||
* @property {string} developer_comment
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} sourceString
|
||||
* @param {string} [description] Defaults to empty string.
|
||||
* @returns {StructuredMessage}
|
||||
*/
|
||||
const makeStructuredMessage = (sourceString, description) => {
|
||||
description = description || '';
|
||||
return {
|
||||
string: sourceString,
|
||||
// We set context because that's what we used to use in the past and removing it now would reset translations.
|
||||
// However, we also set developer_comment because Transifex makes this string much more visible in the
|
||||
// interface than the context.
|
||||
context: description,
|
||||
developer_comment: description
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {{messages: Record<string, StructuredMessage>, allUsedIds: string[]}}
|
||||
*/
|
||||
const parseSourceGuiMessages = () => {
|
||||
const reactTranslationFiles = recursiveReadDirectory(scratchGuiTranslations)
|
||||
.filter((file) => file.endsWith('.json'));
|
||||
|
||||
const messages = {};
|
||||
for (const file of reactTranslationFiles) {
|
||||
const path = pathUtil.join(scratchGuiTranslations, file);
|
||||
const json = JSON.parse(fs.readFileSync(path, 'utf-8'));
|
||||
for (const {id, defaultMessage, description} of json) {
|
||||
messages[id] = makeStructuredMessage(defaultMessage, description);
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse an object like:
|
||||
* {
|
||||
* id: 'something',
|
||||
* description: "something else"
|
||||
* }
|
||||
* @param {string} object
|
||||
* @returns {unknown}
|
||||
*/
|
||||
const parseJsonLike = object => {
|
||||
const result = {};
|
||||
// Remove comments
|
||||
object = object.replace(/\/\/[^\n]*/g, '');
|
||||
for (const lineMatch of object.matchAll(/(\w+):[\s\S]*?(?:'|")(.*)(?:'|")/gm)) {
|
||||
const [_, id, value] = lineMatch;
|
||||
result[id] = value;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {Record<string, StructuredMessage>}
|
||||
*/
|
||||
const parseSourceVmMessages = () => {
|
||||
// Parse all calls to formatMessage()
|
||||
const messages = {};
|
||||
const extensionDirectory = pathUtil.join(scratchVm, 'src');
|
||||
for (const relativePath of recursiveReadDirectory(extensionDirectory)) {
|
||||
const path = pathUtil.join(extensionDirectory, relativePath);
|
||||
const contents = fs.readFileSync(path, 'utf-8');
|
||||
for (const formatMatch of contents.matchAll(/(?:formatMessage|maybeFormatMessage)\({([\s\S]+?)}/g)) {
|
||||
const object = parseJsonLike(formatMatch[1]);
|
||||
if (typeof object.id !== 'string' || typeof object.default !== 'string') {
|
||||
throw new Error('Error parsing formatMessage() string; missing either id or default.');
|
||||
}
|
||||
messages[object.id] = makeStructuredMessage(object.default, object.description);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
};
|
||||
|
||||
const guiMessages = parseSourceGuiMessages();
|
||||
const vmMessages = parseSourceVmMessages();
|
||||
const allMessages = {
|
||||
...guiMessages,
|
||||
...vmMessages
|
||||
};
|
||||
|
||||
const allMessageIds = Object.keys(allMessages).sort();
|
||||
fs.writeFileSync(
|
||||
pathUtil.join(__dirname, 'tw-all-used-ids.json'),
|
||||
JSON.stringify(allMessageIds, null, 4)
|
||||
);
|
||||
|
||||
const twMessages = {};
|
||||
for (const [id, message] of Object.entries(allMessages)) {
|
||||
if (id.startsWith('tw.')) {
|
||||
twMessages[id] = message;
|
||||
}
|
||||
}
|
||||
|
||||
const push = async () => {
|
||||
try {
|
||||
console.log('UPLOADING to Transifex...');
|
||||
const PROJECT = 'turbowarp';
|
||||
const RESOURCE = 'guijson';
|
||||
await txPush(PROJECT, RESOURCE, twMessages);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
push();
|
||||
80
scratch-l10n/scripts/tx-pull-editor.js
Normal file
80
scratch-l10n/scripts/tx-pull-editor.js
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env babel-node
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
* Script to pull translations from transifex and generate the editor-msgs file.
|
||||
* Expects that the project and resource have already been defined in Transifex, and that
|
||||
* the person running the script has the the TX_TOKEN environment variable set to an api
|
||||
* token that has developer access.
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const usage = `
|
||||
Pull supported language translations from Transifex. Usage:
|
||||
node tx-pull-editor.js tx-project tx-resource path
|
||||
tx-project: project on Transifex (e.g., scratch-editor)
|
||||
tx-resource: resource within the project (e.g., interface)
|
||||
path: where to put the downloaded json files
|
||||
NOTE: TX_TOKEN environment variable needs to be set with a Transifex API token. See
|
||||
the Localization page on the GUI wiki for information about setting up Transifex.
|
||||
`;
|
||||
|
||||
// Fail immediately if the TX_TOKEN is not defined
|
||||
if (!process.env.TX_TOKEN || args.length < 3) {
|
||||
process.stdout.write(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {txPull} from '../lib/transifex.js';
|
||||
import {validateTranslations} from '../lib/validate.js';
|
||||
import locales, {localeMap} from '../src/supported-locales.js';
|
||||
import {batchMap} from '../lib/batch.js';
|
||||
|
||||
// Globals
|
||||
const PROJECT = args[0];
|
||||
const RESOURCE = args[1];
|
||||
const OUTPUT_DIR = path.resolve(args[2]);
|
||||
const MODE = 'reviewed';
|
||||
const CONCURRENCY_LIMIT = 36;
|
||||
|
||||
const getLocaleData = async function (locale) {
|
||||
let txLocale = localeMap[locale] || locale;
|
||||
const data = await txPull(PROJECT, RESOURCE, txLocale, MODE);
|
||||
return {
|
||||
locale: locale,
|
||||
translations: data
|
||||
};
|
||||
};
|
||||
|
||||
const pullTranslations = async function () {
|
||||
try {
|
||||
const values = await batchMap(Object.keys(locales), CONCURRENCY_LIMIT, getLocaleData);
|
||||
const source = values.find(elt => elt.locale === 'en').translations;
|
||||
values.forEach(function (translation) {
|
||||
validateTranslations({locale: translation.locale, translations: translation.translations}, source);
|
||||
// if translation has message & description, we only want the message
|
||||
let txs = {};
|
||||
for (const key of Object.keys(translation.translations)) {
|
||||
const tx = translation.translations[key];
|
||||
if (tx.message) {
|
||||
txs[key] = tx.message;
|
||||
} else {
|
||||
txs[key] = tx;
|
||||
}
|
||||
}
|
||||
const file = JSON.stringify(txs, null, 4);
|
||||
fs.writeFileSync(
|
||||
`${OUTPUT_DIR}/${translation.locale}.json`,
|
||||
file
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
process.stdout.write(err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
pullTranslations();
|
||||
34
scratch-l10n/scripts/tx-pull-help-articles.js
Executable file
34
scratch-l10n/scripts/tx-pull-help-articles.js
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
* Script to pull scratch-help translations from transifex and push to FreshDesk.
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const usage = `
|
||||
Pull knowledge base articles from transifex and push to FreshDesk. Usage:
|
||||
node tx-pull-help.js
|
||||
NOTE:
|
||||
FRESHDESK_TOKEN environment variable needs to be set to a FreshDesk API key with
|
||||
access to the Knowledge Base.
|
||||
TX_TOKEN environment variable needs to be set with a Transifex API token. See
|
||||
the Localization page on the GUI wiki for information about setting up Transifex.
|
||||
`;
|
||||
// Fail immediately if the API tokens are not defined, or there any argument
|
||||
if (!process.env.TX_TOKEN || !process.env.FRESHDESK_TOKEN || args.length > 0) {
|
||||
process.stdout.write(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const {getInputs, saveItem, localizeFolder} = require('./help-utils.js');
|
||||
|
||||
getInputs()
|
||||
.then(([languages, folders, names]) => { // eslint-disable-line no-unused-vars
|
||||
process.stdout.write('Processing articles pulled from Transifex\n');
|
||||
return folders.map(item => saveItem(item, languages, localizeFolder));
|
||||
})
|
||||
.catch((e) => {
|
||||
process.stdout.write(`Error: ${e.message}\n`);
|
||||
process.exitCode = 1; // not ok
|
||||
});
|
||||
35
scratch-l10n/scripts/tx-pull-help-names.js
Executable file
35
scratch-l10n/scripts/tx-pull-help-names.js
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
* Script to pull scratch-help translations from transifex and push to FreshDesk.
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const usage = `
|
||||
Pull knowledge base category and folder names from transifex and push to FreshDesk. Usage:
|
||||
node tx-pull-help.js
|
||||
NOTE:
|
||||
FRESHDESK_TOKEN environment variable needs to be set to a FreshDesk API key with
|
||||
access to the Knowledge Base.
|
||||
TX_TOKEN environment variable needs to be set with a Transifex API token. See
|
||||
the Localization page on the GUI wiki for information about setting up Transifex.
|
||||
`;
|
||||
// Fail immediately if the API tokens are not defined, or there any argument
|
||||
if (!process.env.TX_TOKEN || !process.env.FRESHDESK_TOKEN || args.length > 0) {
|
||||
process.stdout.write(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const {getInputs, saveItem, localizeNames} = require('./help-utils.js');
|
||||
|
||||
getInputs()
|
||||
.then(([languages, folders, names]) => { // eslint-disable-line no-unused-vars
|
||||
process.stdout.write('Process Category and Folder Names pulled from Transifex\n');
|
||||
return names.map(item => saveItem(item, languages, localizeNames));
|
||||
})
|
||||
.catch((e) => {
|
||||
process.stdout.write(`Error: ${e.message}\n`);
|
||||
process.exitCode = 1; // not ok
|
||||
});
|
||||
42
scratch-l10n/scripts/tx-pull-locale-articles.js
Executable file
42
scratch-l10n/scripts/tx-pull-locale-articles.js
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
* Script to pull scratch-help translations from transifex and push to FreshDesk.
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const usage = `
|
||||
Pull knowledge base articles from transifexfor debugging translation errors. Usage:
|
||||
node tx-pull-locale-articles.js -d locale-code
|
||||
NOTE:
|
||||
FRESHDESK_TOKEN environment variable needs to be set to a FreshDesk API key with
|
||||
access to the Knowledge Base.
|
||||
TX_TOKEN environment variable needs to be set with a Transifex API token. See
|
||||
the Localization page on the GUI wiki for information about setting up Transifex.
|
||||
`;
|
||||
// Fail immediately if the API tokens are not defined, or missing argument
|
||||
if (!process.env.TX_TOKEN || !process.env.FRESHDESK_TOKEN || args.length === 0) {
|
||||
process.stdout.write(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const {getInputs, saveItem, localizeFolder, debugFolder} = require('./help-utils.js');
|
||||
|
||||
let locale = args[0];
|
||||
let debug = false;
|
||||
if (locale === '-d') {
|
||||
debug = true;
|
||||
locale = args[1];
|
||||
}
|
||||
const saveFn = debug ? debugFolder : localizeFolder;
|
||||
|
||||
getInputs()
|
||||
.then(([languages, folders, names]) => { // eslint-disable-line no-unused-vars
|
||||
process.stdout.write('Processing articles pulled from Transifex\n');
|
||||
return folders.map(item => saveItem(item, [locale], saveFn));
|
||||
})
|
||||
.catch((e) => {
|
||||
process.stdout.write(`Error: ${e.message}\n`);
|
||||
process.exitCode = 1; // not ok
|
||||
});
|
||||
98
scratch-l10n/scripts/tx-pull-www.js
Executable file
98
scratch-l10n/scripts/tx-pull-www.js
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env babel-node
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
* Script to pull www translations from transifex for all resources.
|
||||
* Expects that the project and that the person running the script
|
||||
* has the the TX_TOKEN environment variable set to an api
|
||||
* token that has developer access.
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const usage = `
|
||||
Pull supported language translations from Transifex for the 'scratch-website' project.
|
||||
It will query transifex for the list of resources.
|
||||
Usage:
|
||||
node tx-pull-www.js path [lang]
|
||||
path: root for the translated resources.
|
||||
Each resource will be a subdirectory containing language json files.
|
||||
lang: optional language code - will only pull resources for that language
|
||||
NOTE: TX_TOKEN environment variable needs to be set with a Transifex API token.
|
||||
See the Localization page on the GUI wiki for information about setting up Transifex.
|
||||
`;
|
||||
// Fail immediately if the TX_TOKEN is not defined
|
||||
if (!process.env.TX_TOKEN || args.length < 1) {
|
||||
process.stdout.write(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import mkdirp from 'mkdirp';
|
||||
import {txPull, txResources} from '../lib/transifex.js';
|
||||
import locales, {localeMap} from '../src/supported-locales.js';
|
||||
import {batchMap} from '../lib/batch.js';
|
||||
|
||||
// Globals
|
||||
const PROJECT = 'scratch-website';
|
||||
const OUTPUT_DIR = path.resolve(args[0]);
|
||||
// const MODE = {mode: 'reviewed'}; // default is everything for www
|
||||
const CONCURRENCY_LIMIT = 36;
|
||||
|
||||
const lang = args.length === 2 ? args[1] : '';
|
||||
|
||||
const getLocaleData = async function (item) {
|
||||
const locale = item.locale;
|
||||
const resource = item.resource;
|
||||
let txLocale = localeMap[locale] || locale;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
const translations = await txPull(PROJECT, resource, txLocale);
|
||||
|
||||
const txOutdir = `${OUTPUT_DIR}/${PROJECT}.${resource}`;
|
||||
mkdirp.sync(txOutdir);
|
||||
const fileName = `${txOutdir}/${locale}.json`;
|
||||
fs.writeFileSync(
|
||||
fileName,
|
||||
JSON.stringify(translations, null, 4)
|
||||
);
|
||||
return {
|
||||
resource: resource,
|
||||
locale: locale,
|
||||
file: fileName
|
||||
};
|
||||
} catch (e) {
|
||||
process.stdout.write(`got ${e.message}, retrying after ${i + 1} attempt(s)\n`);
|
||||
}
|
||||
}
|
||||
throw Error('failed to pull translations after 5 retries');
|
||||
};
|
||||
|
||||
const expandResourceFiles = (resources) => {
|
||||
let items = [];
|
||||
for (let resource of resources) {
|
||||
if (lang) {
|
||||
items.push({resource: resource, locale: lang});
|
||||
} else {
|
||||
for (let locale of Object.keys(locales)) {
|
||||
items.push({resource: resource, locale: locale});
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
const pullTranslations = async function () {
|
||||
const resources = await txResources('scratch-website');
|
||||
const allFiles = expandResourceFiles(resources);
|
||||
|
||||
try {
|
||||
await batchMap(allFiles, CONCURRENCY_LIMIT, getLocaleData);
|
||||
} catch (err) {
|
||||
console.error(err); // eslint-disable-line no-console
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
pullTranslations();
|
||||
141
scratch-l10n/scripts/tx-push-help.js
Executable file
141
scratch-l10n/scripts/tx-push-help.js
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env babel-node
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
* Script get Knowledge base articles from Freshdesk and push them to transifex.
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const {txPush, txCreateResource} = require('../lib/transifex.js');
|
||||
|
||||
const usage = `
|
||||
Pull knowledge base articles from Freshdesk and push to scratch-help project on transifex. Usage:
|
||||
node tx-push-help.js
|
||||
NOTE:
|
||||
FRESHDESK_TOKEN environment variable needs to be set to a FreshDesk API key with
|
||||
access to the Knowledge Base.
|
||||
TX_TOKEN environment variable needs to be set with a Transifex API token. See
|
||||
the Localization page on the GUI wiki for information about setting up Transifex.
|
||||
`;
|
||||
// Fail immediately if the API tokens are not defined, or there any argument
|
||||
if (!process.env.TX_TOKEN || !process.env.FRESHDESK_TOKEN || args.length > 0) {
|
||||
process.stdout.write(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
import FreshdeskApi from './freshdesk-api.js';
|
||||
|
||||
const FD = new FreshdeskApi('https://mitscratch.freshdesk.com', process.env.FRESHDESK_TOKEN);
|
||||
const TX_PROJECT = 'scratch-help';
|
||||
|
||||
const categoryNames = {};
|
||||
const folderNames = {};
|
||||
|
||||
/**
|
||||
* Generate a transifex id from the name and id field of an objects. Remove spaces and '/'
|
||||
* from the name and append '.<id>' Transifex ids (slugs) have a max length of 50. Use at most
|
||||
* 30 characters of the name to allow for Freshdesk id, and a suffix like '_json'
|
||||
* @param {object} item data from Freshdesk that includes the name and id of a category or folder
|
||||
* @return {string} generated transifex id
|
||||
*/
|
||||
const makeTxId = item => {
|
||||
return `${item.name.replace(/[ /]/g, '').slice(0, 30)}_${item.id}`;
|
||||
};
|
||||
|
||||
const txPushResource = async (name, articles, type) => {
|
||||
const resourceData = {
|
||||
slug: name,
|
||||
name: name,
|
||||
i18n_type: type,
|
||||
priority: 0, // default to normal priority
|
||||
content: articles
|
||||
};
|
||||
|
||||
try {
|
||||
await txPush(TX_PROJECT, name, articles);
|
||||
} catch (err) {
|
||||
if (err.statusCode !== 404) {
|
||||
process.stdout.write(`Transifex Error: ${err.message}\n`);
|
||||
process.stdout.write(
|
||||
`Transifex Error ${err.response.statusCode.toString()}: ${err.response.body}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// file not found - create it, but also give message
|
||||
process.stdout.write(`Transifex Resource not found, creating: ${name}\n`);
|
||||
if (err.statusCode === 404) {
|
||||
await txCreateResource(TX_PROJECT, resourceData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* get a flattened list of folders
|
||||
* @param {category} categories array of categories the folders belong to
|
||||
* @return {Promise} flattened list of folders
|
||||
*/
|
||||
const getFolders = async (categories) => {
|
||||
let categoryFolders = await Promise.all( // eslint-disable-line no-undef
|
||||
categories.map(category => FD.listFolders(category))
|
||||
);
|
||||
return [].concat(...categoryFolders);
|
||||
};
|
||||
|
||||
const PUBLISHED = 2; // in Freshdesk, draft status = 1, and published = 2
|
||||
const saveArticles = (folder) => {
|
||||
FD.listArticles(folder)
|
||||
.then(json => {
|
||||
let txArticles = json.reduce((strings, current) => {
|
||||
if (current.status === PUBLISHED) {
|
||||
strings[`${current.id}`] = {
|
||||
title: {
|
||||
string: current.title
|
||||
},
|
||||
description: {
|
||||
string: current.description
|
||||
}
|
||||
};
|
||||
if (current.tags.length > 0) {
|
||||
strings[`${current.id}`].tags = {string: current.tags.toString()};
|
||||
}
|
||||
}
|
||||
return strings;
|
||||
}, {});
|
||||
process.stdout.write(`Push ${folder.name} articles to Transifex\n`);
|
||||
txPushResource(`${makeTxId(folder)}_json`, txArticles, 'STRUCTURED_JSON');
|
||||
});
|
||||
};
|
||||
const getArticles = async (folders) => {
|
||||
return Promise.all(folders.map(folder => saveArticles(folder))); // eslint-disable-line no-undef
|
||||
};
|
||||
|
||||
const syncSources = async () => {
|
||||
let status = 0;
|
||||
status = await FD.listCategories()
|
||||
.then(json => {
|
||||
// save category names for translation
|
||||
for (let cat of json.values()) {
|
||||
categoryNames[`${makeTxId(cat)}`] = cat.name;
|
||||
}
|
||||
return json;
|
||||
})
|
||||
.then(getFolders)
|
||||
.then(data => {
|
||||
data.forEach(item => {
|
||||
folderNames[`${makeTxId(item)}`] = item.name;
|
||||
});
|
||||
process.stdout.write('Push category and folder names to Transifex\n');
|
||||
txPushResource('categoryNames_json', categoryNames, 'KEYVALUEJSON');
|
||||
txPushResource('folderNames_json', folderNames, 'KEYVALUEJSON');
|
||||
return data;
|
||||
})
|
||||
.then(getArticles)
|
||||
.catch((e) => {
|
||||
process.stdout.write(`Error:${e.message}\n`);
|
||||
return 1;
|
||||
});
|
||||
process.exitCode = status;
|
||||
};
|
||||
|
||||
syncSources();
|
||||
100
scratch-l10n/scripts/tx-push-src.js
Executable file
100
scratch-l10n/scripts/tx-push-src.js
Executable file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
* Script to upload a source en.json file to a particular transifex project resource.
|
||||
* Expects that the project and resource have already been defined in Transifex, and that
|
||||
* the person running the script has the the TX_TOKEN environment variable set to an api
|
||||
* token that has developer access.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {txPush, txCreateResource} = require('../lib/transifex.js');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const usage = `
|
||||
Push English source strings to Transifex. Usage:
|
||||
node tx-push-src.js tx-project tx-resource english-json-file
|
||||
tx-project: the project slug on transifex
|
||||
tx-resource: the resource slug on transifex
|
||||
english-json-file: path to the en.json source
|
||||
NOTE: TX_TOKEN environment variable needs to be set with a Transifex API token. See
|
||||
the Localization page on the GUI wiki for information about setting up Transifex.
|
||||
`;
|
||||
|
||||
// Exit if missing arguments or TX_TOKEN
|
||||
if (args.length < 3 || !process.env.TX_TOKEN) {
|
||||
process.stdout.write(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Globals
|
||||
const PROJECT = args[0];
|
||||
const RESOURCE = args[1];
|
||||
|
||||
let en = fs.readFileSync(path.resolve(args[2]));
|
||||
en = JSON.parse(en);
|
||||
|
||||
// get the correct resource file type based on transifex project/repo and resource
|
||||
const getResourceType = (project, resource) => {
|
||||
if (project === 'scratch-website') {
|
||||
// all the resources are KEYVALUEJSON
|
||||
return 'KEYVALUEJSON';
|
||||
}
|
||||
if (project === 'scratch-legacy') {
|
||||
// all the resources are po files
|
||||
return 'PO';
|
||||
}
|
||||
if (project === 'scratch-editor') {
|
||||
if (resource === 'blocks') {
|
||||
return 'KEYVALUEJSON';
|
||||
}
|
||||
// everything else is CHROME I18N JSON
|
||||
return 'CHROME';
|
||||
}
|
||||
if (project === 'scratch-videos') {
|
||||
// all the resources are srt files
|
||||
return 'SRT';
|
||||
}
|
||||
if (project === 'scratch-android') {
|
||||
// all the resources are android xml files
|
||||
return 'ANDROID';
|
||||
}
|
||||
if (project === 'scratch-resources') {
|
||||
// all the resources are Chrome format json files
|
||||
return 'CHROME';
|
||||
}
|
||||
process.stdout.write(`Error - Unknown resource type for:\n`);
|
||||
process.stdout.write(` Project: ${project}, resource: ${resource}\n`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
// update Transifex with English source
|
||||
const pushSource = async function () {
|
||||
try {
|
||||
await txPush(PROJECT, RESOURCE, en);
|
||||
} catch (err) {
|
||||
if (err.statusCode !== 404) {
|
||||
process.stdout.write(`Transifex Error: ${err.message}\n`);
|
||||
process.stdout.write(
|
||||
`Transifex Error ${err.response.statusCode.toString()}: ${err.response.body}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// file not found - create it, but also give message
|
||||
process.stdout.write(`Transifex Resource not found, creating: ${RESOURCE}\n`);
|
||||
const resourceData = {
|
||||
slug: RESOURCE,
|
||||
name: RESOURCE,
|
||||
priority: 0, // default to normal priority
|
||||
i18nType: getResourceType(PROJECT, RESOURCE),
|
||||
content: en
|
||||
};
|
||||
await txCreateResource(PROJECT, resourceData);
|
||||
process.exitCode = 0;
|
||||
}
|
||||
};
|
||||
|
||||
pushSource();
|
||||
15
scratch-l10n/scripts/update-translations.sh
Executable file
15
scratch-l10n/scripts/update-translations.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# script for syncing translations from transifex and comitting the changes.
|
||||
|
||||
# exit script if any command returns a non-zero return code:
|
||||
set -ev
|
||||
|
||||
npm run pull:editor
|
||||
npm run pull:www
|
||||
npm run test
|
||||
|
||||
# commit any updates and push. Build and release should happen on the push not here.
|
||||
git add .
|
||||
git commit -m "fix: pull new editor translations from Transifex"
|
||||
git push https://${GITHUB_TOKEN}@github.com/${TRAVIS_REPO_SLUG}.git HEAD:master
|
||||
86
scratch-l10n/scripts/validate-extension-inputs.js
Normal file
86
scratch-l10n/scripts/validate-extension-inputs.js
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env babel-node
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
* Script to validate extension block input placeholders
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import async from 'async';
|
||||
import assert from 'assert';
|
||||
import locales from '../src/supported-locales.js';
|
||||
|
||||
// Globals
|
||||
const JSON_DIR = path.join(process.cwd(), '/editor/extensions');
|
||||
const source = JSON.parse(fs.readFileSync(`${JSON_DIR}/en.json`));
|
||||
|
||||
// Matches everything inside brackets, and the brackets themselves.
|
||||
// e.g. matches '[MOTOR_ID]', '[POWER]' from 'altera a potência de [MOTOR_ID] para [POWER]'
|
||||
const blockInputRegex = /\[.+?\]/g;
|
||||
|
||||
let numTotalErrors = 0;
|
||||
|
||||
const validateExtensionInputs = (translationData, locale) => {
|
||||
let numLocaleErrors = 0;
|
||||
for (const block of Object.keys(translationData)) {
|
||||
const englishBlockInputs = source[block].match(blockInputRegex);
|
||||
|
||||
if (!englishBlockInputs) continue;
|
||||
|
||||
// If null (meaning no matches), that means that English block inputs exist but translated ones don't.
|
||||
// Coerce it to an empty array so that the assertion below fails, instead of getting the less-helpful error
|
||||
// that we can't call Array.includes on null.
|
||||
const translatedBlockInputs = translationData[block].match(blockInputRegex) || [];
|
||||
|
||||
for (const input of englishBlockInputs) {
|
||||
// Currently there are enough errors here that it would be tedious to fix an error, rerun this tool
|
||||
// to find the next error, and repeat. So, catch the assertion error and add to the number of total errors.
|
||||
// This allows all errors to be displayed when the command is run, rather than just the first encountered.
|
||||
try {
|
||||
assert(
|
||||
translatedBlockInputs.includes(input),
|
||||
|
||||
`Block '${block}' in locale '${locale}' does not include input ${input}:\n` +
|
||||
translationData[block]
|
||||
);
|
||||
} catch (err) {
|
||||
numLocaleErrors++;
|
||||
console.error(err.message + '\n'); // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (numLocaleErrors > 0) {
|
||||
numTotalErrors += numLocaleErrors;
|
||||
throw new Error(`${numLocaleErrors} total error(s) for locale '${locale}'`);
|
||||
}
|
||||
};
|
||||
|
||||
const validate = (locale, callback) => {
|
||||
fs.readFile(`${JSON_DIR}/${locale}.json`, function (err, data) {
|
||||
if (err) callback(err);
|
||||
// let this throw an error if invalid json
|
||||
data = JSON.parse(data);
|
||||
|
||||
try {
|
||||
validateExtensionInputs(data, locale);
|
||||
} catch (error) {
|
||||
console.error(error.message + '\n'); // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
callback();
|
||||
});
|
||||
};
|
||||
|
||||
async.each(Object.keys(locales), validate, function (err) {
|
||||
if (err) {
|
||||
console.error(err); // eslint-disable-line no-console
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (numTotalErrors > 0) {
|
||||
console.error(`${numTotalErrors} total extension input error(s)`); // eslint-disable-line no-console
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
48
scratch-l10n/scripts/validate-translations.js
Normal file
48
scratch-l10n/scripts/validate-translations.js
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env babel-node
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
* Script to validate the translation json
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const usage = `
|
||||
Validate translation json. Usage:
|
||||
babel-node validate_translations.js path
|
||||
path: where to find the downloaded json files
|
||||
`;
|
||||
// Fail immediately if the TX_TOKEN is not defined
|
||||
if (args.length < 1) {
|
||||
process.stdout.write(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import async from 'async';
|
||||
import {validateTranslations} from '../lib/validate.js';
|
||||
import locales from '../src/supported-locales.js';
|
||||
|
||||
// Globals
|
||||
const JSON_DIR = path.resolve(args[0]);
|
||||
|
||||
const source = JSON.parse(fs.readFileSync(`${JSON_DIR}/en.json`));
|
||||
|
||||
const validate = (locale, callback) => {
|
||||
fs.readFile(`${JSON_DIR}/${locale}.json`, function (err, data) {
|
||||
if (err) callback(err);
|
||||
// let this throw an error if invalid json
|
||||
data = JSON.parse(data);
|
||||
const translations = {
|
||||
locale: locale,
|
||||
translations: data
|
||||
};
|
||||
validateTranslations(translations, source);
|
||||
});
|
||||
};
|
||||
|
||||
async.each(Object.keys(locales), validate, function (err) {
|
||||
if (err) {
|
||||
console.error(err); // eslint-disable-line no-console
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
63
scratch-l10n/scripts/validate-www.js
Executable file
63
scratch-l10n/scripts/validate-www.js
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env babel-node
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
* Script to validate the www translation json
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const usage = `
|
||||
Validate translation json. Usage:
|
||||
babel-node validate_www.js path
|
||||
path: root folder for all the www resource folders
|
||||
`;
|
||||
if (args.length < 1) {
|
||||
process.stdout.write(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import glob from 'glob';
|
||||
import async from 'async';
|
||||
import {validateTranslations} from '../lib/validate.js';
|
||||
import locales from '../src/supported-locales.js';
|
||||
|
||||
// Globals
|
||||
const WWW_DIR = path.resolve(args[0]);
|
||||
const RESOURCES = glob.sync(`${path.resolve(WWW_DIR)}/*`);
|
||||
|
||||
const validate = (localeData, callback) => {
|
||||
fs.readFile(localeData.localeFileName, function (err, data) {
|
||||
if (err) callback(err);
|
||||
// let this throw an error if invalid json
|
||||
data = JSON.parse(data);
|
||||
const translations = {
|
||||
locale: localeData.locale,
|
||||
translations: data
|
||||
};
|
||||
validateTranslations(translations, localeData.sourceData);
|
||||
});
|
||||
};
|
||||
|
||||
const validateResource = (resource, callback) => {
|
||||
const source = JSON.parse(fs.readFileSync(`${resource}/en.json`));
|
||||
const allLocales = Object.keys(locales).map(loc => {
|
||||
return {
|
||||
locale: loc,
|
||||
localeFileName: `${resource}/${loc}.json`,
|
||||
sourceData: source
|
||||
};
|
||||
});
|
||||
async.each(allLocales, validate, function (err) {
|
||||
if (err) {
|
||||
callback(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
async.each(RESOURCES, validateResource, function (err) {
|
||||
if (err) {
|
||||
console.error(err); // eslint-disable-line no-console
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user