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:
2026-06-16 15:37:45 +08:00
commit 6e0a1fbcbb
11350 changed files with 965674 additions and 0 deletions

View 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);
}

View 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));

View 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;

View 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
});
}
};

View 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"
]

View 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
};

View 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
View 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();

View 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();

View 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
});

View 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
});

View 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
});

View 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();

View 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();

View 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();

View 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

View 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);
}
});

View 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);
}
});

View 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);
}
});