Preliminary localization (#777)

* localize the block and menu strings in the pen extension
* adds .tx/config to be able to push translations to transifex
* includes format-message to localize strings and extracting them.
* add setLocale function to VM to allow GUI to pass in locale data.
* refresh block definitions when the locale changes.

### Still to be decided
For now just extracting messages from the pen extension into their own file. We’ll need to decide if each category gets its own file, or group all the strings into one resource.
This commit is contained in:
chrisgarrity 2017-12-11 15:41:45 -05:00 committed by GitHub
parent f33c6294bc
commit f51cf9877e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 210 additions and 22 deletions

3
.gitignore vendored
View file

@ -16,3 +16,6 @@ npm-*
# Build
/dist
/playground
# Localization
/translations

8
.tx/config Normal file
View file

@ -0,0 +1,8 @@
[main]
host = https://www.transifex.com
[experimental-scratch.pen]
file_filter = translations/pen/<lang>.json
source_file = translations/pen/en.json
source_lang = en
type = CHROME

View file

@ -15,7 +15,9 @@
"build": "webpack --progress --colors --bail",
"coverage": "tap ./test/{unit,integration}/*.js --coverage --coverage-report=lcov",
"deploy": "touch playground/.nojekyll && gh-pages -t -d playground -m \"Build for $(git log --pretty=format:%H -n1)\"",
"lint": "eslint .",
"extract:pen": "mkdirp translations/pen && format-message extract --out-file translations/pen/en.json src/extensions/scratch3_pen/index.js",
"i18n:src": "npm run extract:pen",
"lint": "eslint . && format-message lint src/**/*.js",
"prepublish": "in-publish && npm run build || not-in-publish",
"start": "webpack-dev-server",
"tap": "tap ./test/{unit,integration}/*.js",
@ -37,6 +39,8 @@
"eslint": "^4.5.0",
"eslint-config-scratch": "^5.0.0",
"expose-loader": "0.7.4",
"format-message": "5.2.1",
"format-message-cli": "5.2.1",
"gh-pages": "^1.1.0",
"highlightjs": "^9.8.0",
"htmlparser2": "3.9.2",

View file

@ -385,6 +385,14 @@ class Runtime extends EventEmitter {
return 'EXTENSION_ADDED';
}
/**
* Event name for reporting that blocksInfo was updated.
* @const {string}
*/
static get BLOCKSINFO_UPDATE () {
return 'BLOCKSINFO_UPDATE';
}
/**
* How rapidly we try to step threads by default, in ms.
*/
@ -497,6 +505,41 @@ class Runtime extends EventEmitter {
this.emit(Runtime.EXTENSION_ADDED, categoryInfo.blocks.concat(categoryInfo.menus));
}
/**
* Reregister the primitives for an extension
* @param {ExtensionInfo} extensionInfo - new info (results of running getInfo)
* for an extension
* @private
*/
_refreshExtensionPrimitives (extensionInfo) {
let extensionBlocks = [];
for (const categoryInfo of this._blockInfo) {
if (extensionInfo.id === categoryInfo.id) {
categoryInfo.blocks = [];
categoryInfo.menus = [];
for (const menuName in extensionInfo.menus) {
if (extensionInfo.menus.hasOwnProperty(menuName)) {
const menuItems = extensionInfo.menus[menuName];
const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuItems, categoryInfo);
categoryInfo.menus.push(convertedMenu);
}
}
for (const blockInfo of extensionInfo.blocks) {
const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo);
const opcode = convertedBlock.json.type;
categoryInfo.blocks.push(convertedBlock);
this._primitives[opcode] = convertedBlock.info.func;
if (blockInfo.blockType === BlockType.HAT) {
this._hats[opcode] = {edgeActivated: true}; /** @TODO let extension specify this */
}
}
extensionBlocks = extensionBlocks.concat(categoryInfo.blocks, categoryInfo.menus);
}
}
this.emit(Runtime.BLOCKSINFO_UPDATE, extensionBlocks);
}
/**
* Build the scratch-blocks JSON for a menu. Note that scratch-blocks treats menus as a special kind of block.
* @param {string} menuName - the name of the menu

View file

@ -78,7 +78,7 @@ class ExtensionManager {
* @type {Set.<string>}
* @private
*/
this._loadedExtensions = new Set();
this._loadedExtensions = new Map();
/**
* Keep a reference to the runtime so we can construct internal extension objects.
@ -119,8 +119,8 @@ class ExtensionManager {
const extension = builtinExtensions[extensionURL];
const extensionInstance = new extension(this.runtime);
return this._registerInternalExtension(extensionInstance).then(() => {
this._loadedExtensions.add(extensionURL);
return this._registerInternalExtension(extensionInstance).then(serviceName => {
this._loadedExtensions.set(extensionURL, serviceName);
});
}
@ -133,6 +133,21 @@ class ExtensionManager {
});
}
/**
* regenerate blockinfo for any loaded extensions
*/
refreshBlocks () {
this._loadedExtensions.forEach(serviceName => {
dispatch.call(serviceName, 'getInfo')
.then(info => {
dispatch.call('runtime', '_refreshExtensionPrimitives', info);
})
.catch(e => {
log.error(`Failed to refresh buildtin extension primitives: ${JSON.stringify(e)}`);
});
});
}
allocateWorker () {
const id = this.nextExtensionWorker++;
const workerInfo = this.pendingExtensions.shift();
@ -175,7 +190,10 @@ class ExtensionManager {
const fakeWorkerId = this.nextExtensionWorker++;
const serviceName = `extension.${fakeWorkerId}.${extensionInfo.id}`;
return dispatch.setService(serviceName, extensionObject)
.then(() => dispatch.call('extensions', 'registerExtensionService', serviceName));
.then(() => {
dispatch.call('extensions', 'registerExtensionService', serviceName);
return serviceName;
});
}
/**

View file

@ -3,6 +3,7 @@ const BlockType = require('../../extension-support/block-type');
const Cast = require('../../util/cast');
const Clone = require('../../util/clone');
const Color = require('../../util/color');
const formatMessage = require('format-message');
const MathUtil = require('../../util/math-util');
const RenderedTarget = require('../../sprites/rendered-target');
const log = require('../../util/log');
@ -15,7 +16,7 @@ const log = require('../../util/log');
const iconURI = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU+cGVuLWljb248L3RpdGxlPjxnIHN0cm9rZT0iIzU3NUU3NSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik04Ljc1MyAzNC42MDJsLTQuMjUgMS43OCAxLjc4My00LjIzN2MxLjIxOC0yLjg5MiAyLjkwNy01LjQyMyA1LjAzLTcuNTM4TDMxLjA2NiA0LjkzYy44NDYtLjg0MiAyLjY1LS40MSA0LjAzMi45NjcgMS4zOCAxLjM3NSAxLjgxNiAzLjE3My45NyA0LjAxNUwxNi4zMTggMjkuNTljLTIuMTIzIDIuMTE2LTQuNjY0IDMuOC03LjU2NSA1LjAxMiIgZmlsbD0iI0ZGRiIvPjxwYXRoIGQ9Ik0yOS40MSA2LjExcy00LjQ1LTIuMzc4LTguMjAyIDUuNzcyYy0xLjczNCAzLjc2Ni00LjM1IDEuNTQ2LTQuMzUgMS41NDYiLz48cGF0aCBkPSJNMzYuNDIgOC44MjVjMCAuNDYzLS4xNC44NzMtLjQzMiAxLjE2NGwtOS4zMzUgOS4zYy4yODItLjI5LjQxLS42NjguNDEtMS4xMiAwLS44NzQtLjUwNy0xLjk2My0xLjQwNi0yLjg2OC0xLjM2Mi0xLjM1OC0zLjE0Ny0xLjgtNC4wMDItLjk5TDMwLjk5IDUuMDFjLjg0NC0uODQgMi42NS0uNDEgNC4wMzUuOTYuODk4LjkwNCAxLjM5NiAxLjk4MiAxLjM5NiAyLjg1NU0xMC41MTUgMzMuNzc0Yy0uNTczLjMwMi0xLjE1Ny41Ny0xLjc2NC44M0w0LjUgMzYuMzgybDEuNzg2LTQuMjM1Yy4yNTgtLjYwNC41My0xLjE4Ni44MzMtMS43NTcuNjkuMTgzIDEuNDQ4LjYyNSAyLjEwOCAxLjI4Mi42Ni42NTggMS4xMDIgMS40MTIgMS4yODcgMi4xMDIiIGZpbGw9IiM0Qzk3RkYiLz48cGF0aCBkPSJNMzYuNDk4IDguNzQ4YzAgLjQ2NC0uMTQuODc0LS40MzMgMS4xNjVsLTE5Ljc0MiAxOS42OGMtMi4xMyAyLjExLTQuNjczIDMuNzkzLTcuNTcyIDUuMDFMNC41IDM2LjM4bC45NzQtMi4zMTYgMS45MjUtLjgwOGMyLjg5OC0xLjIxOCA1LjQ0LTIuOSA3LjU3LTUuMDFsMTkuNzQzLTE5LjY4Yy4yOTItLjI5Mi40MzItLjcwMi40MzItMS4xNjUgMC0uNjQ2LS4yNy0xLjQtLjc4LTIuMTIyLjI1LjE3Mi41LjM3Ny43MzcuNjE0Ljg5OC45MDUgMS4zOTYgMS45ODMgMS4zOTYgMi44NTYiIGZpbGw9IiM1NzVFNzUiIG9wYWNpdHk9Ii4xNSIvPjxwYXRoIGQ9Ik0xOC40NSAxMi44M2MwIC41LS40MDQuOTA1LS45MDQuOTA1cy0uOTA1LS40MDUtLjkwNS0uOTA0YzAtLjUuNDA3LS45MDMuOTA2LS45MDMuNSAwIC45MDQuNDA0LjkwNC45MDR6IiBmaWxsPSIjNTc1RTc1Ii8+PC9nPjwvc3ZnPg==';
/**
* Enum for pen color parameters.
* Enum for pen color parameter values.
* @readonly
* @enum {string}
*/
@ -201,6 +202,49 @@ class Scratch3PenBlocks {
return MathUtil.wrapClamp(value, 0, 100);
}
/**
* Initialize color parameters menu with localized strings
* @returns {array} of the localized text and values for each menu element
* @private
*/
_initColorParam () {
return [
{
text: formatMessage({
id: 'pen.colorMenu.color',
default: 'color',
description: 'label for color element in color picker for pen extension'
}),
value: ColorParam.COLOR
},
{
text: formatMessage({
id: 'pen.colorMenu.saturation',
default: 'saturation',
description: 'label for saturation element in color picker for pen extension'
}),
value: ColorParam.SATURATION
},
{
text: formatMessage({
id: 'pen.colorMenu.brightness',
default: 'brightness',
description: 'label for brightness element in color picker for pen extension'
}),
value: ColorParam.BRIGHTNESS
},
{
text: formatMessage({
id: 'pen.colorMenu.transparency',
default: 'transparency',
description: 'label for transparency element in color picker for pen extension'
}),
value: ColorParam.TRANSPARENCY
}
];
}
/**
* Clamp a pen color parameter to the range (0,100).
* @param {number} value - the value to be clamped.
@ -246,26 +290,48 @@ class Scratch3PenBlocks {
blocks: [
{
opcode: 'clear',
blockType: BlockType.COMMAND
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.clear',
default: 'clear',
description: 'erase all pen trails and stamps'
})
},
{
opcode: 'stamp',
blockType: BlockType.COMMAND
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.stamp',
default: 'stamp',
description: 'render current costume on the background'
})
},
{
opcode: 'penDown',
blockType: BlockType.COMMAND,
text: 'pen down'
text: formatMessage({
id: 'pen.penDown',
default: 'pen down',
description: 'start leaving a trail when the sprite moves'
})
},
{
opcode: 'penUp',
blockType: BlockType.COMMAND,
text: 'pen up'
text: formatMessage({
id: 'pen.penUp',
default: 'pen up',
description: 'stop leaving a trail behind the sprite'
})
},
{
opcode: 'setPenColorToColor',
blockType: BlockType.COMMAND,
text: 'set pen color to [COLOR]',
text: formatMessage({
id: 'pen.setColor',
default: 'set pen color to [COLOR]',
description: 'set the pen color to a particular (RGB) value'
}),
arguments: {
COLOR: {
type: ArgumentType.COLOR
@ -275,7 +341,11 @@ class Scratch3PenBlocks {
{
opcode: 'changePenColorParamBy',
blockType: BlockType.COMMAND,
text: 'change pen [COLOR_PARAM] by [VALUE]',
text: formatMessage({
id: 'pen.changeColorParam',
default: 'change pen [COLOR_PARAM] by [VALUE]',
description: 'change the state of a pen color parameter'
}),
arguments: {
COLOR_PARAM: {
type: ArgumentType.STRING,
@ -291,7 +361,11 @@ class Scratch3PenBlocks {
{
opcode: 'setPenColorParamTo',
blockType: BlockType.COMMAND,
text: 'set pen [COLOR_PARAM] to [VALUE]',
text: formatMessage({
id: 'pen.setColorParam',
default: 'set pen [COLOR_PARAM] to [VALUE]',
description: 'set the state for a pen color parameter e.g. saturation'
}),
arguments: {
COLOR_PARAM: {
type: ArgumentType.STRING,
@ -307,7 +381,11 @@ class Scratch3PenBlocks {
{
opcode: 'changePenSizeBy',
blockType: BlockType.COMMAND,
text: 'change pen size by [SIZE]',
text: formatMessage({
id: 'pen.changeSize',
default: 'change pen size by [SIZE]',
description: 'change the diameter of the trail left by a sprite'
}),
arguments: {
SIZE: {
type: ArgumentType.NUMBER,
@ -318,7 +396,11 @@ class Scratch3PenBlocks {
{
opcode: 'setPenSizeTo',
blockType: BlockType.COMMAND,
text: 'set pen size to [SIZE]',
text: formatMessage({
id: 'pen.setSize',
default: 'set pen size to [SIZE]',
description: 'set the diameter of a trail left by a sprite'
}),
arguments: {
SIZE: {
type: ArgumentType.NUMBER,
@ -330,7 +412,11 @@ class Scratch3PenBlocks {
{
opcode: 'setPenShadeToNumber',
blockType: BlockType.COMMAND,
text: 'set pen shade to [SHADE]',
text: formatMessage({
id: 'pen.setShade',
default: 'set pen shade to [SHADE]',
description: 'legacy pen blocks - set pen shade'
}),
arguments: {
SHADE: {
type: ArgumentType.NUMBER,
@ -342,7 +428,11 @@ class Scratch3PenBlocks {
{
opcode: 'changePenShadeBy',
blockType: BlockType.COMMAND,
text: 'change pen shade by [SHADE]',
text: formatMessage({
id: 'pen.changeShade',
default: 'change pen shade by [SHADE]',
description: 'legacy pen blocks - change pen shade'
}),
arguments: {
SHADE: {
type: ArgumentType.NUMBER,
@ -354,7 +444,11 @@ class Scratch3PenBlocks {
{
opcode: 'setPenHueToNumber',
blockType: BlockType.COMMAND,
text: 'set pen hue to [HUE]',
text: formatMessage({
id: 'pen.setHue',
default: 'set pen hue to [HUE]',
description: 'legacy pen blocks - set pen color to number'
}),
arguments: {
HUE: {
type: ArgumentType.NUMBER,
@ -366,7 +460,11 @@ class Scratch3PenBlocks {
{
opcode: 'changePenHueBy',
blockType: BlockType.COMMAND,
text: 'change pen hue by [HUE]',
text: formatMessage({
id: 'pen.changeHue',
default: 'change pen hue by [HUE]',
description: 'legacy pen blocks - change pen color'
}),
arguments: {
HUE: {
type: ArgumentType.NUMBER,
@ -377,9 +475,7 @@ class Scratch3PenBlocks {
}
],
menus: {
colorParam:
[ColorParam.COLOR, ColorParam.SATURATION,
ColorParam.BRIGHTNESS, ColorParam.TRANSPARENCY]
colorParam: this._initColorParam()
}
};
}

View file

@ -7,6 +7,7 @@ const Runtime = require('./engine/runtime');
const sb2 = require('./serialization/sb2');
const sb3 = require('./serialization/sb3');
const StringUtil = require('./util/string-util');
const formatMessage = require('format-message');
const {loadCostume} = require('./import/load-costume.js');
const {loadSound} = require('./import/load-sound.js');
@ -67,6 +68,9 @@ class VirtualMachine extends EventEmitter {
this.runtime.on(Runtime.EXTENSION_ADDED, blocksInfo => {
this.emit(Runtime.EXTENSION_ADDED, blocksInfo);
});
this.runtime.on(Runtime.BLOCKSINFO_UPDATE, blocksInfo => {
this.emit(Runtime.BLOCKSINFO_UPDATE, blocksInfo);
});
this.extensionManager = new ExtensionManager(this.runtime);
@ -552,6 +556,18 @@ class VirtualMachine extends EventEmitter {
this.runtime.attachStorage(storage);
}
/**
* set the current locale and builtin messages for the VM
* @param {[type]} locale current locale
* @param {[type]} messages builtin messages map for current locale
*/
setLocale (locale, messages) {
if (locale !== formatMessage.setup().locale) {
formatMessage.setup({locale: locale, translations: {[locale]: messages}});
this.extensionManager.refreshBlocks();
}
}
/**
* Handle a Blockly event for the current editing target.
* @param {!Blockly.Event} e Any Blockly event.