diff --git a/.gitignore b/.gitignore index 960930abc4..4a32dff77b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ # npm node_modules -# Final built website +# Final built website and localizations build +build-l10n # Various operating system caches thumbs.db diff --git a/development/build-production.js b/development/build-production.js index 39f50abffe..acfe878b40 100644 --- a/development/build-production.js +++ b/development/build-production.js @@ -1,10 +1,14 @@ const pathUtil = require("path"); const Builder = require("./builder"); -const outputDirectory = pathUtil.join(__dirname, "..", "build"); +const outputDirectory = pathUtil.join(__dirname, "../build"); +const l10nOutput = pathUtil.join(__dirname, "../build-l10n"); const builder = new Builder("production"); const build = builder.build(); + build.export(outputDirectory); +console.log(`Built to ${outputDirectory}`); -console.log(`Saved to ${outputDirectory}`); +build.exportL10N(l10nOutput); +console.log(`Exported L10N to ${l10nOutput}`); diff --git a/development/builder.js b/development/builder.js index 9f8a0c40c5..73dbca1bd0 100644 --- a/development/builder.js +++ b/development/builder.js @@ -3,12 +3,17 @@ const AdmZip = require("adm-zip"); const pathUtil = require("path"); const compatibilityAliases = require("./compatibility-aliases"); const parseMetadata = require("./parse-extension-metadata"); -const featuredExtensionSlugs = require("../extensions/extensions.json"); /** * @typedef {'development'|'production'|'desktop'} Mode */ +/** + * @typedef TranslatableString + * @property {string} string The English version of the string + * @property {string} developer_comment Helper text to help translators + */ + /** * Recursively read a directory. * @param {string} directory @@ -39,6 +44,107 @@ const recursiveReadDirectory = (directory) => { return result; }; +/** + * Synchronous create a directory and any parents. Does nothing if the folder already exists. + * @param {string} directory + */ +const mkdirp = (directory) => { + try { + fs.mkdirSync(directory, { + recursive: true, + }); + } catch (e) { + if (e.code !== "ENOENT") { + throw e; + } + } +}; + +/** + * @param {Record>} allTranslations + * @param {string} idPrefix + * @returns {Record>|null} + */ +const filterTranslationsByPrefix = (allTranslations, idPrefix) => { + let translationsEmpty = true; + const filteredTranslations = {}; + + for (const [locale, strings] of Object.entries(allTranslations)) { + let localeEmpty = true; + const filteredStrings = {}; + + for (const [id, string] of Object.entries(strings)) { + if (id.startsWith(idPrefix)) { + filteredStrings[id.substring(idPrefix.length)] = string; + localeEmpty = false; + } + } + + if (!localeEmpty) { + filteredTranslations[locale] = filteredStrings; + translationsEmpty = false; + } + } + + return translationsEmpty ? null : filteredTranslations; +}; + +/** + * @param {Record>} allTranslations + * @param {string} idFilter + * @returns {Record} + */ +const filterTranslationsByID = (allTranslations, idFilter) => { + let stringsEmpty = true; + const result = {}; + + for (const [locale, strings] of Object.entries(allTranslations)) { + const translated = strings[idFilter]; + if (translated) { + result[locale] = translated; + stringsEmpty = false; + } + } + + return stringsEmpty ? null : result; +}; + +/** + * @param {string} oldCode + * @param {string} insertCode + */ +const insertAfterCommentsBeforeCode = (oldCode, insertCode) => { + let index = 0; + while (true) { + if (oldCode.substring(index, index + 2) === "//") { + // Line comment + const end = oldCode.indexOf("\n", index); + if (end === -1) { + // This file is only line comments + index = oldCode.length; + break; + } + index = end; + } else if (oldCode.substring(index, index + 2) === "/*") { + // Block comment + const end = oldCode.indexOf("*/", index); + if (end === -1) { + throw new Error("Block comment never ends"); + } + index = end + 2; + } else if (/\s/.test(oldCode.charAt(index))) { + // Whitespace + index++; + } else { + break; + } + } + + const before = oldCode.substring(0, index); + const after = oldCode.substring(index); + return before + insertCode + after; +}; + class BuildFile { constructor(source) { this.sourcePath = source; @@ -59,12 +165,53 @@ class BuildFile { validate() { // no-op by default } + + /** + * @returns {Record>|null} + */ + getStrings() { + // no-op by default, to be overridden + return null; + } } class ExtensionFile extends BuildFile { - constructor(absolutePath, featured) { + /** + * @param {string} absolutePath Full path to the .js file, eg. /home/.../extensions/fetch.js + * @param {string} slug Just the extension ID from the path, eg. fetch + * @param {boolean} featured true if the extension is the homepage + * @param {Record>} allTranslations All extension runtime translations + * @param {Mode} mode + */ + constructor(absolutePath, slug, featured, allTranslations, mode) { super(absolutePath); + /** @type {string} */ + this.slug = slug; + /** @type {boolean} */ this.featured = featured; + /** @type {Record>} */ + this.allTranslations = allTranslations; + /** @type {Mode} */ + this.mode = mode; + } + + read() { + const data = fs.readFileSync(this.sourcePath, "utf-8"); + + if (this.mode !== "development") { + const translations = filterTranslationsByPrefix( + this.allTranslations, + `${this.slug}@` + ); + if (translations !== null) { + return insertAfterCommentsBeforeCode( + data, + `Scratch.translate.setup(${JSON.stringify(translations)});` + ); + } + } + + return data; } getMetadata() { @@ -116,10 +263,59 @@ class ExtensionFile extends BuildFile { } } } + + getStrings() { + if (!this.featured) { + return null; + } + + const metadata = this.getMetadata(); + const slug = this.slug; + + const getMetadataDescription = (part) => { + let result = `${part} of the '${metadata.name}' extension in the extension gallery.`; + if (metadata.context) { + result += ` ${metadata.context}`; + } + return result; + }; + const metadataStrings = { + [`${slug}@name`]: { + string: metadata.name, + developer_comment: getMetadataDescription("Name"), + }, + [`${slug}@description`]: { + string: metadata.description, + developer_comment: getMetadataDescription("Description"), + }, + }; + + const parseTranslations = require("./parse-extension-translations"); + const jsCode = fs.readFileSync(this.sourcePath, "utf-8"); + const unprefixedRuntimeStrings = parseTranslations(jsCode); + const runtimeStrings = Object.fromEntries( + Object.entries(unprefixedRuntimeStrings).map(([key, value]) => [ + `${slug}@${key}`, + value, + ]) + ); + + return { + "extension-metadata": metadataStrings, + "extension-runtime": runtimeStrings, + }; + } } class HomepageFile extends BuildFile { - constructor(extensionFiles, extensionImages, withDocs, samples, mode) { + constructor( + extensionFiles, + extensionImages, + featuredSlugs, + withDocs, + samples, + mode + ) { super(pathUtil.join(__dirname, "homepage-template.ejs")); /** @type {Record} */ @@ -128,6 +324,9 @@ class HomepageFile extends BuildFile { /** @type {Record} */ this.extensionImages = extensionImages; + /** @type {string[]} */ + this.featuredSlugs = featuredSlugs; + /** @type {Map} */ this.withDocs = withDocs; @@ -179,7 +378,7 @@ class HomepageFile extends BuildFile { .map((i) => i[0]); const extensionMetadata = Object.fromEntries( - featuredExtensionSlugs.map((slug) => [ + this.featuredSlugs.map((slug) => [ slug, { ...this.extensionFiles[slug].getMetadata(), @@ -203,7 +402,14 @@ class HomepageFile extends BuildFile { } class JSONMetadataFile extends BuildFile { - constructor(extensionFiles, extensionImages, withDocs, samples) { + constructor( + extensionFiles, + extensionImages, + featuredSlugs, + withDocs, + samples, + allTranslations + ) { super(null); /** @type {Record} */ @@ -212,11 +418,17 @@ class JSONMetadataFile extends BuildFile { /** @type {Record} */ this.extensionImages = extensionImages; + /** @type {string[]} */ + this.featuredSlugs = featuredSlugs; + /** @type {Set} */ this.withDocs = withDocs; /** @type {Map} */ this.samples = samples; + + /** @type {Record>} */ + this.allTranslations = allTranslations; } getType() { @@ -225,7 +437,7 @@ class JSONMetadataFile extends BuildFile { read() { const extensions = []; - for (const extensionSlug of featuredExtensionSlugs) { + for (const extensionSlug of this.featuredSlugs) { const extension = {}; const file = this.extensionFiles[extensionSlug]; const metadata = file.getMetadata(); @@ -233,8 +445,28 @@ class JSONMetadataFile extends BuildFile { extension.slug = extensionSlug; extension.id = metadata.id; + + // English fields extension.name = metadata.name; extension.description = metadata.description; + + // For other languages, translations go here. + // This system is a bit silly to avoid backwards-incompatible JSON changes. + const nameTranslations = filterTranslationsByID( + this.allTranslations, + `${extensionSlug}@name` + ); + if (nameTranslations) { + extension.nameTranslations = nameTranslations; + } + const descriptionTranslations = filterTranslationsByID( + this.allTranslations, + `${extensionSlug}@description` + ); + if (descriptionTranslations) { + extension.descriptionTranslations = descriptionTranslations; + } + if (image) { extension.image = image; } @@ -385,6 +617,7 @@ class SampleFile extends BuildFile { class Build { constructor() { + /** @type {Record} */ this.files = {}; } @@ -398,15 +631,7 @@ class Build { } export(root) { - try { - fs.rmSync(root, { - recursive: true, - }); - } catch (e) { - if (e.code !== "ENOENT") { - throw e; - } - } + mkdirp(root); for (const [relativePath, file] of Object.entries(this.files)) { const directoryName = pathUtil.dirname(relativePath); @@ -416,6 +641,50 @@ class Build { fs.writeFileSync(pathUtil.join(root, relativePath), file.read()); } } + + /** + * @returns {Record>} + */ + generateL10N() { + const allStrings = {}; + + for (const file of Object.values(this.files)) { + const fileStrings = file.getStrings(); + if (!fileStrings) { + continue; + } + + for (const [group, strings] of Object.entries(fileStrings)) { + if (!allStrings[group]) { + allStrings[group] = {}; + } + + for (const [key, value] of Object.entries(strings)) { + if (allStrings[key]) { + throw new Error( + `L10N collision: multiple instances of ${key} in group ${group}` + ); + } + allStrings[group][key] = value; + } + } + } + + return allStrings; + } + + /** + * @param {string} root + */ + exportL10N(root) { + mkdirp(root); + + const groups = this.generateL10N(); + for (const [name, strings] of Object.entries(groups)) { + const filename = pathUtil.join(root, `exported-${name}.json`); + fs.writeFileSync(filename, JSON.stringify(strings, null, 2)); + } + } } class Builder { @@ -439,11 +708,35 @@ class Builder { this.imagesRoot = pathUtil.join(__dirname, "../images"); this.docsRoot = pathUtil.join(__dirname, "../docs"); this.samplesRoot = pathUtil.join(__dirname, "../samples"); + this.translationsRoot = pathUtil.join(__dirname, "../translations"); } build() { const build = new Build(this.mode); + const featuredExtensionSlugs = JSON.parse( + fs.readFileSync( + pathUtil.join(this.extensionsRoot, "extensions.json"), + "utf-8" + ) + ); + + /** + * Look up by [group][locale][id] + * @type {Record>>} + */ + const translations = {}; + for (const [filename, absolutePath] of recursiveReadDirectory( + this.translationsRoot + )) { + if (!filename.endsWith(".json")) { + continue; + } + const group = filename.split(".")[0]; + const data = JSON.parse(fs.readFileSync(absolutePath, "utf-8")); + translations[group] = data; + } + /** @type {Record} */ const extensionFiles = {}; for (const [filename, absolutePath] of recursiveReadDirectory( @@ -454,7 +747,13 @@ class Builder { } const extensionSlug = filename.split(".")[0]; const featured = featuredExtensionSlugs.includes(extensionSlug); - const file = new ExtensionFile(absolutePath, featured); + const file = new ExtensionFile( + absolutePath, + extensionSlug, + featured, + translations["extension-runtime"], + this.mode + ); extensionFiles[extensionSlug] = file; build.files[`/${filename}`] = file; } @@ -530,6 +829,7 @@ class Builder { build.files["/index.html"] = new HomepageFile( extensionFiles, extensionImages, + featuredExtensionSlugs, extensionsWithDocs, samples, this.mode @@ -541,8 +841,10 @@ class Builder { new JSONMetadataFile( extensionFiles, extensionImages, + featuredExtensionSlugs, extensionsWithDocs, - samples + samples, + translations["extension-metadata"] ); for (const [oldPath, newPath] of Object.entries(compatibilityAliases)) { @@ -581,6 +883,7 @@ class Builder { `${this.websiteRoot}/**/*`, `${this.docsRoot}/**/*`, `${this.samplesRoot}/**/*`, + `${this.translationsRoot}/**/*`, ], { ignoreInitial: true, diff --git a/development/parse-extension-metadata.js b/development/parse-extension-metadata.js index 632f01216c..935a322bcc 100644 --- a/development/parse-extension-metadata.js +++ b/development/parse-extension-metadata.js @@ -24,6 +24,7 @@ class Extension { this.by = []; /** @type {Person[]} */ this.original = []; + this.context = ""; } } @@ -94,6 +95,9 @@ const parseMetadata = (extensionCode) => { case "original": metadata.original.push(parsePerson(value)); break; + case "context": + metadata.context = value; + break; default: // TODO break; diff --git a/development/parse-extension-translations.js b/development/parse-extension-translations.js new file mode 100644 index 0000000000..624a7ca7c4 --- /dev/null +++ b/development/parse-extension-translations.js @@ -0,0 +1,123 @@ +const espree = require("espree"); +const esquery = require("esquery"); +const parseMetadata = require("./parse-extension-metadata"); + +/** + * @fileoverview Parses extension code to find calls to Scratch.translate() and statically + * evaluate its arguments. + */ + +const evaluateAST = (node) => { + if (node.type == "Literal") { + return node.value; + } + + if (node.type === "ObjectExpression") { + const object = {}; + for (const { key, value } of node.properties) { + // Normally Identifier refers to a variable, but inside of key we treat it as a string. + let evaluatedKey; + if (key.type === "Identifier") { + evaluatedKey = key.name; + } else { + evaluatedKey = evaluateAST(key); + } + + object[evaluatedKey] = evaluateAST(value); + } + return object; + } + + console.error(`Can't evaluate node:`, node); + throw new Error(`Can't evaluate ${node.type} node at build-time`); +}; + +/** + * Generate default ID for a translation that has no explicit ID. + * @param {string} string + * @returns {string} + */ +const defaultIdForString = (string) => { + // hardcoded in VM + return `_${string}`; +}; + +/** + * @param {string} js + * @returns {Record} + */ +const parseTranslations = (js) => { + const metadata = parseMetadata(js); + if (!metadata.name) { + throw new Error(`Extension needs a // Name: to generate translations`); + } + + let defaultDescription = `Part of the '${metadata.name}' extension.`; + if (metadata.context) { + defaultDescription += ` ${metadata.context}`; + } + + const ast = espree.parse(js, { + ecmaVersion: 2022, + }); + const selector = esquery.parse( + 'CallExpression[callee.object.name="Scratch"][callee.property.name="translate"]' + ); + const matches = esquery.match(ast, selector); + + const result = {}; + for (const match of matches) { + const args = match.arguments; + if (args.length !== 1) { + throw new Error(`Scratch.translate() must have exactly 1 argument`); + } + + const evaluated = evaluateAST(args[0]); + + let id; + let english; + let description; + + if (typeof evaluated === "string") { + id = defaultIdForString(evaluated); + english = evaluated; + description = defaultDescription; + } else if (typeof evaluated === "object" && evaluated !== null) { + english = evaluated.default; + id = evaluated.id || defaultIdForString(english); + + description = [defaultDescription, evaluated.description] + .filter((i) => i) + .join(" "); + } else { + throw new Error( + `Not a valid argument for Scratch.translate(): ${evaluated}` + ); + } + + if (typeof id !== "string") { + throw new Error( + `Scratch.translate() passed a value for id that is not a string: ${id}` + ); + } + if (typeof english !== "string") { + throw new Error( + `Scratch.translate() passed a value for default that is not a string: ${english}` + ); + } + if (typeof description !== "string") { + throw new Error( + `Scratch.translate() passed a value for description that is not a string: ${description}` + ); + } + + result[id] = { + string: english, + developer_comment: description, + }; + } + + return result; +}; + +module.exports = parseTranslations; diff --git a/extensions/.eslintrc.js b/extensions/.eslintrc.js index 3fc982110c..969f9a4ef4 100644 --- a/extensions/.eslintrc.js +++ b/extensions/.eslintrc.js @@ -84,6 +84,10 @@ module.exports = { { selector: 'Program > :not(ExpressionStatement[expression.type=CallExpression][expression.callee.type=/FunctionExpression/])', message: 'All extension code must be within (function (Scratch) { ... })(Scratch);' + }, + { + selector: 'CallExpression[callee.object.object.name=Scratch][callee.object.property.name=translate][callee.property.name=setup]', + message: 'Do not call Scratch.translate.setup() yourself. Just use Scratch.translate() and let the build script handle it.' } ] } diff --git a/extensions/0832/rxFS2.js b/extensions/0832/rxFS2.js index 871ef0b1ca..0ba12451ef 100644 --- a/extensions/0832/rxFS2.js +++ b/extensions/0832/rxFS2.js @@ -15,51 +15,6 @@ (function (Scratch) { "use strict"; - Scratch.translate.setup({ - zh: { - start: "新建 [STR] ", - folder: "设置 [STR] 为 [STR2] ", - folder_default: "大主教大祭司主宰世界!", - sync: "将 [STR] 的位置更改为 [STR2] ", - del: "删除 [STR] ", - webin: "从网络加载 [STR]", - open: "打开 [STR]", - clean: "清空文件系统", - in: "从 [STR] 导入文件系统", - out: "导出文件系统", - list: "列出 [STR] 下的所有文件", - search: "搜索 [STR]", - }, - ru: { - start: "Создать [STR]", - folder: "Установить [STR] в [STR2]", - folder_default: "Архиепископ Верховный жрец Правитель мира!", - sync: "Изменить расположение [STR] на [STR2]", - del: "Удалить [STR]", - webin: "Загрузить [STR] из Интернета", - open: "Открыть [STR]", - clean: "Очистить файловую систему", - in: "Импортировать файловую систему из [STR]", - out: "Экспортировать файловую систему", - list: "Список всех файлов в [STR]", - search: "Поиск [STR]", - }, - jp: { - start: "新規作成 [STR]", - folder: "[STR] を [STR2] に設定する", - folder_default: "大主教大祭司世界の支配者!", - sync: "[STR] の位置を [STR2] に変更する", - del: "[STR] を削除する", - webin: "[STR] をウェブから読み込む", - open: "[STR] を開く", - clean: "ファイルシステムをクリアする", - in: "[STR] からファイルシステムをインポートする", - out: "ファイルシステムをエクスポートする", - list: "[STR] にあるすべてのファイルをリストする", - search: "[STR] を検索する", - }, - }); - var rxFSfi = new Array(); var rxFSsy = new Array(); var Search, i, str, str2; diff --git a/extensions/Lily/McUtils.js b/extensions/Lily/McUtils.js index 889c32e80b..8cf8177d8d 100644 --- a/extensions/Lily/McUtils.js +++ b/extensions/Lily/McUtils.js @@ -2,6 +2,7 @@ // ID: lmsmcutils // Description: Helpful utilities for any fast food employee. // By: LilyMakesThings +// Context: Joke extension based on McDonalds, a fast food chain. /*! * Credit to NexusKitten (NamelessCat) for the idea diff --git a/extensions/NOname-awa/graphics2d.js b/extensions/NOname-awa/graphics2d.js index 44bf8d5f3a..6c74ebedfe 100644 --- a/extensions/NOname-awa/graphics2d.js +++ b/extensions/NOname-awa/graphics2d.js @@ -5,24 +5,6 @@ (function (Scratch) { "use strict"; - Scratch.translate.setup({ - zh: { - name: "图形 2D", - line_section: "([x1],[y1])到([x2],[y2])的距离", - ray_direction: "([x1],[y1])到([x2],[y2])的方向", - triangle: "三角形([x1],[y1])([x2],[y2])([x3],[y3])的 [CS]", - triangle_s: "三角形 [s1] [s2] [s3] 的面积", - quadrilateral: - "四边形([x1],[y1])([x2],[y2])([x3],[y3])([x4],[y4])的 [CS]", - graph: "图形 [graph] 的 [CS]", - round: "[rd] 为 [a] 的圆的 [CS]", - pi: "派", - radius: "半径", - diameter: "直径", - area: "面积", - circumference: "周长", - }, - }); class graph { getInfo() { return { diff --git a/extensions/qxsck/data-analysis.js b/extensions/qxsck/data-analysis.js index b8732c5180..195d95f71d 100644 --- a/extensions/qxsck/data-analysis.js +++ b/extensions/qxsck/data-analysis.js @@ -5,18 +5,6 @@ (function (Scratch) { "use strict"; - Scratch.translate.setup({ - zh: { - name: "数据分析", - average: "[NUMBERS] 的平均数", - maximum: "[NUMBERS] 的最大数", - minimum: "[NUMBERS] 的最小数", - median: "[NUMBERS] 的中位数", - mode: "[NUMBERS] 的众数", - variance: "[NUMBERS] 的方差", - }, - }); - class dataAnalysis { getInfo() { return { diff --git a/extensions/qxsck/var-and-list.js b/extensions/qxsck/var-and-list.js index f2fe5663a7..68779b7866 100644 --- a/extensions/qxsck/var-and-list.js +++ b/extensions/qxsck/var-and-list.js @@ -5,26 +5,6 @@ (function (Scratch) { "use strict"; - Scratch.translate.setup({ - zh: { - name: "变量与列表", - getVar: "[VAR] 的值", - seriVarsToJson: "将以 [START] 为开头的所有变量转换为json", - setVar: "将变量 [VAR] 的值设置为 [VALUE]", - getList: "列表 [LIST] 的值", - getValueOfList: "列表 [LIST] 的第 [INDEX] 项", - seriListsToJson: "将以 [START] 为开头的所有列表转换为json", - clearList: "清空列表 [LIST]", - deleteOfList: "删除列表 [LIST] 的第 [INDEX] 项", - addValueInList: "在列表 [LIST] 末尾添加 [VALUE]", - replaceOfList: "替换列表 [LIST] 的第 [INDEX] 项为 [VALUE]", - getIndexOfList: "列表 [LIST] 中第一个 [VALUE] 的位置", - getIndexesOfList: "列表 [LIST] 中 [VALUE] 的位置", - length: "列表 [LIST] 的长度", - listContains: "列表 [LIST] 包括 [VALUE] 吗?", - copyList: "将列表 [LIST1] 复制到列表 [LIST2]", - }, - }); class VarAndList { getInfo() { return { diff --git a/package-lock.json b/package-lock.json index 031e704254..fb28fd4b84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -138,7 +138,7 @@ }, "@turbowarp/types": { "version": "git+https://github.com/TurboWarp/types-tw.git#cfaa5d3ce5dc96f16f7da9054325a4f1ed457662", - "from": "git+https://github.com/TurboWarp/types-tw.git#cfaa5d3ce5dc96f16f7da9054325a4f1ed457662" + "from": "git+https://github.com/TurboWarp/types-tw.git#tw" }, "@ungap/structured-clone": { "version": "1.2.0", diff --git a/package.json b/package.json index f2d888700e..365e4787ab 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ }, "devDependencies": { "eslint": "^8.53.0", + "espree": "^9.6.1", + "esquery": "^1.5.0", "prettier": "^3.0.3" }, "private": true diff --git a/translations/extension-metadata.json b/translations/extension-metadata.json new file mode 100644 index 0000000000..10f48de680 --- /dev/null +++ b/translations/extension-metadata.json @@ -0,0 +1,289 @@ +{ + "ca": { + "runtime-options@name": "Opcions d'execució" + }, + "cs": { + "runtime-options@name": "Nastavení běhu" + }, + "de": { + "-SIPC-/consoles@description": "Blöcke, die mit der in die Entwicklertools Ihres Browsers integrierten JavaScript-Konsole interagieren", + "-SIPC-/time@name": "Zeit", + "0832/rxFS2@description": "Blöcke für das Interagieren mit einem virtuellen im Arbeitsspeicher befindlichem Dateisystem", + "CubesterYT/TurboHook@description": "Ermöglicht die Benutzung von Webhooks", + "Lily/SoundExpanded@description": "Fügt mehr Blöcke hinzu, die mit Klängen zu tun haben", + "Lily/TempVariables2@name": "Temporäre Variablen", + "NOname-awa/more-comparisons@name": "Mehr Vergleiche", + "ar@name": "Erweiterte Realität", + "battery@name": "Batterie", + "clipboard@name": "Zwischenablage", + "files@name": "Dateien", + "lab/text@name": "Animierter Text", + "mdwalters/notifications@name": "Benachrichtigungen", + "runtime-options@name": "Laufzeit-Optionen", + "shreder95ua/resolution@name": "Bildschirmauflösung", + "sound@name": "Klänge", + "true-fantom/math@name": "Mathe", + "veggiecan/LongmanDictionary@name": "Longman Wörterbuch" + }, + "es": { + "CST1229/zip@description": "Crea y edita archivos de formato .zip, incluyendo archivos .sb3.", + "Lily/MoreTimers@description": "Controlar varios contadores a la vez.", + "NOname-awa/graphics2d@name": "Gráficos 2D", + "runtime-options@name": "Opciones de Runtime", + "text@name": "Texto" + }, + "fr": { + "runtime-options@name": "Options d'exécution" + }, + "hu": { + "runtime-options@name": "Lefutási Opciók" + }, + "it": { + "-SIPC-/consoles@description": "Blocchi che interagiscono con la console Javascript degli strumenti per sviluppatori del browser. ", + "-SIPC-/consoles@name": "Console Javascript", + "-SIPC-/time@description": "Blocchi per interagire con i timestamp Unix e altre stringhe rappresentanti ora e data.", + "-SIPC-/time@name": "Unix Time", + "0832/rxFS2@description": "Blocchi per gestire un filesystem vituale in memoria.", + "Alestore/nfcwarp@description": "Permette di leggere i dati da dispositivi NFC (NDEF). Funziona solo in Chrome e Android.", + "Alestore/nfcwarp@name": "NFC Warp", + "CST1229/zip@description": "Crea e modifica i file in formato zip, inclusi i file sb3.", + "Clay/htmlEncode@description": "Inserisce caratteri escape nelle stringhe per poterle includere in tutta sicurezza nell'HTML. ", + "Clay/htmlEncode@name": "HTML Encoding", + "CubesterYT/TurboHook@description": "Permette di usare i webhook.", + "CubesterYT/WindowControls@description": "Sposta, ridimensiona e rinomina le finestre, entra in modalità schermo intero, restituisce le dimensioni dello schermo, e molto altro.", + "CubesterYT/WindowControls@name": "Controllo Finestre", + "DNin/wake-lock@description": "Impedisce al tuo computer di andare in standby.", + "DNin/wake-lock@name": "Blocco Standby", + "DT/cameracontrols@description": "Sposta la parte visibile dello Stage.", + "DT/cameracontrols@name": "Controllo Webcam (Ancora Presenti Diversi Bug)", + "JeremyGamer13/tween@description": "Metodi di interpolazione per rendere più fluide le animazioni.", + "JeremyGamer13/tween@name": "Animazioni Interpolate", + "Lily/AllMenus@description": "Categoria speciale che contiene tutti i menu di tutte le categorie e tutte le estensioni di Scratch.", + "Lily/AllMenus@name": "Tutti i Menu", + "Lily/Cast@description": "Converte i valori da un tipo all'altro.", + "Lily/Cast@name": "Conversione", + "Lily/ClonesPlus@description": "Estensione delle possibilità dei cloni di Scratch.", + "Lily/ClonesPlus@name": "Cloni Plus", + "Lily/CommentBlocks@description": "Aggiunge commenti ai tuoi script.", + "Lily/CommentBlocks@name": "Blocchi per Commenti", + "Lily/HackedBlocks@description": "Vari \"blocchi hackerati\" che funzionano in Scratch ma non sono visibili nell'elenco dei blocchi.", + "Lily/HackedBlocks@name": "Collezione di Blocchi Nascosti", + "Lily/LooksPlus@description": "Espande la categoria Aspetto, permette di mostrare/nascondere, di ottenere i dati SVG dei costumi e di modificare i costumi SVG degli sprite.", + "Lily/LooksPlus@name": "Aspetto Plus", + "Lily/McUtils@description": "Blocchi utili per qualunque impiegato di un fast food.", + "Lily/McUtils@name": "Utilità FastFood", + "Lily/MoreEvents@description": "Avvia i tuoi script in nuovi modi.", + "Lily/MoreEvents@name": "Altri Eventi", + "Lily/MoreTimers@description": "Permette di gestire più cronometri.", + "Lily/MoreTimers@name": "Altri Cronometri", + "Lily/Skins@description": "Cambia il costumi dei tuoi sprite con altre immagini o altri costumi.", + "Lily/Skins@name": "Altro Costumi Plus ", + "Lily/SoundExpanded@description": "Aggiunge altri blocchi per gestire i suoni.", + "Lily/SoundExpanded@name": "Suoni Plus", + "Lily/TempVariables2@description": "Crea variabili usa e getta o variabili limitate ai singoli thread.", + "Lily/TempVariables2@name": "Variabili Temporanee", + "Lily/Video@description": "Riproduce un video dal suo URL .", + "Lily/lmsutils@description": "Conosciuta in precedenza come \"Utilità per LMS\".", + "Lily/lmsutils@name": "Strumenti di Lily", + "Longboost/color_channels@description": "Mostra o timbra solo i canali RGB selezionati.", + "Longboost/color_channels@name": "Canali RGB", + "NOname-awa/graphics2d@description": "Blocchi per calcolare distanza, angoli e aree in due dimensioni.", + "NOname-awa/graphics2d@name": "Grafica 2D", + "NOname-awa/more-comparisons@description": "Ulteriori blocchi per fare confronti.", + "NOname-awa/more-comparisons@name": "Altri Confronti", + "NexusKitten/controlcontrols@description": "Mostra e nasconde i pulsanti di controllo dei progetti.", + "NexusKitten/controlcontrols@name": "Gestione Pulsanti di Controllo", + "NexusKitten/moremotion@description": "Altri blocchi relativi al movimento.", + "NexusKitten/moremotion@name": "Movimento Plus", + "NexusKitten/sgrab@description": "Ottieni informazioni sui progetti e sugli utenti Scratch. ", + "Skyhigh173/bigint@description": "Blocchi matematici che funzionano su numeri interi (ossia senza decimali) infinitamente grandi.", + "Skyhigh173/bigint@name": "Numeri Illimitati", + "Skyhigh173/json@description": "Gestisce stringhe e array JSON.", + "TheShovel/CanvasEffects@description": "Applica effetti visivi a tutto lo Stage.", + "TheShovel/CanvasEffects@name": "Effetti Stage", + "TheShovel/ColorPicker@description": "Accede al contagocce di sistema.", + "TheShovel/ColorPicker@name": "Contagocce", + "TheShovel/CustomStyles@description": "Personalizza l'aspetto dei monitor delle variabili e della casella CHIEDI del tuo progetto.", + "TheShovel/CustomStyles@name": "Stili Personalizzati", + "TheShovel/LZ-String@description": "Comprime e decomprime testi usando l'algoritmo lz-string.", + "TheShovel/LZ-String@name": "Compressione LZ", + "TheShovel/ShovelUtils@description": "Blocchi vari.", + "TheShovel/ShovelUtils@name": "Utilità Varie", + "Xeltalliv/clippingblending@description": "Ritaglio di immagini al di fuori di una zona rettangolare predefinita e modalità aggiuntive di mescolamento dei colori.", + "Xeltalliv/clippingblending@name": "Ritaglio e Fusione", + "XeroName/Deltatime@description": "Blocchi deltatime di precisione.", + "ZXMushroom63/searchApi@description": "Interagisce con i parametri di ricerca dell'URL, la parte dell'URL dopo il punto interrogativo.", + "ZXMushroom63/searchApi@name": "Parametri di Ricerca URL", + "ar@description": "Mostra immagini della webcam e ne traccia il movimento, permettendo ai progetti 3D di sovrapporsi correttamente agli oggetti del mondo reale.", + "ar@name": "Realtà Aumentata", + "battery@description": "Accede alle informazioni sulla batteria di telefoni e portatili. Può non funzionare su tutti i dispositivi o tutti i browser.", + "battery@name": "Batteria", + "bitwise@description": "Blocchi che operano su numeri binari.", + "bitwise@name": "Operazioni su Bit", + "box2d@description": "Fisica bidimensionale.", + "box2d@name": "Fisica Box2D", + "clipboard@description": "Legge e scrive gli appunti di sistema.", + "clipboard@name": "Appunti", + "clouddata-ping@description": "Determina se un server di variabili cloud è probabilmente attivo.", + "clouddata-ping@name": "Ping Dati Cloud", + "cloudlink@description": "Una potente estensione WebSocket per Scratch.", + "cs2627883/numericalencoding@description": "Codifica stringhe come numeri per memorizzarle nelle variabili cloud.", + "cs2627883/numericalencoding@name": "Codifica Numerica", + "cursor@description": "Usa puntatori del mouse personalizzati o nasconde il puntatore. Permette anche di rimpiazzare il puntatore con le immagini di un qualunque costume.", + "cursor@name": "Puntatore Mouse", + "encoding@description": "Codifica e decodifica stringhe nei corrispondenti numeri unicode, base 64 o URL.", + "encoding@name": "Codifica", + "fetch@description": "Invia richieste al web.", + "files@description": "Legge e scarica file.", + "files@name": "File", + "gamejolt@description": "Blocchi che permettono ai giochi di interagire con l'API GemeJolt. Non ufficiale.", + "gamepad@description": "Accede direttamente ai gamepad invece di mappare soltanto i pulsanti in tasti.", + "godslayerakp/http@description": "Estensione completa per interagire con siti web esterni.", + "godslayerakp/ws@description": "Connessione manuale a server WebSocket.", + "iframe@description": "Mostra pagine web o HTML nello Stage.", + "itchio@description": "Blocchi che interagiscono con il sito itch.io. Non ufficiale.", + "lab/text@description": "Un modo semplice per mostrare e animare il testo. Compatibie con i blocchi sperimentali \"Testo Animato\" di Scratch Lab.", + "lab/text@name": "Testo Animato", + "local-storage@description": "Memorizza dati persistenti. Come i cookie, ma in modo migliore.", + "local-storage@name": "Memoria Locale", + "mdwalters/notifications@description": "Mostra le notifiche.", + "mdwalters/notifications@name": "Notifiche", + "navigator@description": "Dettagli relativi al browser utente e al sistema operativo.", + "navigator@name": "Browser e SO", + "obviousAlexC/SensingPlus@description": "Un'estensione della categoria Sensori.", + "obviousAlexC/SensingPlus@name": "Sensori Plus", + "obviousAlexC/newgroundsIO@description": "Blocchi che permettono ai giochi di interagire con l'API Newgrounds API. Non ufficiale.", + "obviousAlexC/penPlus@description": "Capacità di rendering avanzate.", + "obviousAlexC/penPlus@name": "Penna Plus V6", + "penplus@description": "Rimpiazzata da Penna Plus V6.", + "penplus@name": "Penna Plus V5 (Vecchio)", + "pointerlock@description": "Aggiunge blocchi per bloccare il mouse. I blocchi\" x/y del mouse\" restituiscono di quanto è cambiata la posizione rispetto al frame precedente mentre il puntatore è bloccato. Rimpiazza il \"blocco puntatore\" sperimentale.", + "pointerlock@name": "Blocco Puntatore", + "qxsck/data-analysis@description": "Blocchi che calcolano medie, mediane, massimi, minimi, varianze e mode.", + "qxsck/data-analysis@name": "Analisi dei Dati", + "qxsck/var-and-list@description": "Ulteriori blocchi per gestione delle variabili e delle liste.", + "qxsck/var-and-list@name": "Variabili e liste", + "rixxyx@description": "Blocchi vari.", + "runtime-options@description": "Restituisce e modifica le impostazioni per la modalità turbo, il framerate, l'interpolazione, i limiti dei cloni, le dimensioni dello Stage e altro ancora.", + "runtime-options@name": "Opzioni Esecuzione", + "shreder95ua/resolution@description": "Restituisce la risoluzione dello schermo principale.", + "shreder95ua/resolution@name": "Risoluzione Schermo", + "sound@description": "Riproduce suoni dai loro URL.", + "sound@name": "Suoni", + "stretch@description": "Stira gli sprite in orizzontale e in verticale.", + "stretch@name": "Stira", + "text@description": "Manipola caratteri e testi.", + "text@name": "Testo", + "true-fantom/base@description": "Converte i numeri tra basi diverse.", + "true-fantom/base@name": "Basi", + "true-fantom/couplers@description": "Alcuni blocchi adattatori.", + "true-fantom/couplers@name": "Adattatori", + "true-fantom/math@description": "Diversi blocchi di tipo operatore, dall'esponente alle funzioni trigonometriche.", + "true-fantom/math@name": "Matematica", + "true-fantom/network@description": "Vari blocchi per interagire con la rete", + "true-fantom/network@name": "Rete", + "true-fantom/regexp@description": "Interfaccia complet per lavorare con le Espressioni Regolari.", + "utilities@description": "Diversi blocchi interessanti.", + "utilities@name": "Utilità", + "veggiecan/LongmanDictionary@description": "Permette al tuo progetto di recuperare le definizioni delle parole inglesi del Dizionario Longman.", + "veggiecan/LongmanDictionary@name": "Dizionario Longman", + "veggiecan/browserfullscreen@description": "Entra e esce nella modalità schermo intero.", + "veggiecan/browserfullscreen@name": "Modalità Schermo Intero", + "vercte/dictionaries@description": "Usa la struttura dizionario (coppie attributo/valore) nei tuoi progetti e converti da e in JSON.", + "vercte/dictionaries@name": "Dizionari" + }, + "ja": { + "-SIPC-/consoles@name": "コンソール", + "-SIPC-/time@name": "時間", + "Clay/htmlEncode@name": "HTMLエンコード", + "runtime-options@name": "ランタイムのオプション", + "text@name": "テキスト" + }, + "ja-hira": { + "runtime-options@name": "ランタイムのオプション" + }, + "ko": { + "runtime-options@name": "실행 설정", + "text@name": "텍스트" + }, + "lt": { + "runtime-options@name": "Paleidimo laiko parinktys" + }, + "nl": { + "runtime-options@name": "Looptijdopties", + "text@name": "Tekst" + }, + "pl": { + "runtime-options@name": "Opcje Uruchamiania" + }, + "pt": { + "runtime-options@name": "Opções de Execução" + }, + "pt-br": { + "runtime-options@name": "Opções de Execução" + }, + "ru": { + "runtime-options@name": "Опции Выполнения" + }, + "sl": { + "runtime-options@name": "Možnosti izvajanja" + }, + "sv": { + "runtime-options@name": "Körtidsalternativ" + }, + "tr": { + "runtime-options@name": "Çalışma Zamanı Seçenekleri", + "text@name": "Metin" + }, + "uk": { + "runtime-options@name": "Параметри виконання" + }, + "zh-cn": { + "-SIPC-/consoles@description": "存取JS开发者控制台", + "-SIPC-/consoles@name": "控制台", + "-SIPC-/time@description": "处理UNIX时间戳和日期字符串", + "-SIPC-/time@name": "时间", + "0832/rxFS2@description": "创建并使用虚拟档案系统", + "Alestore/nfcwarp@name": "NFC", + "JeremyGamer13/tween@name": "缓动", + "Lily/AllMenus@name": "全部菜单", + "Lily/Cast@description": "转换Scratch的资料类型", + "Lily/Cast@name": "类型转换", + "Lily/ClonesPlus@name": "克隆+", + "Lily/CommentBlocks@description": "给代码添加注释
", + "Lily/CommentBlocks@name": "注释
", + "Lily/LooksPlus@name": "外观+", + "Lily/MoreEvents@name": "更多事件", + "Lily/MoreTimers@name": "更多计时器", + "Lily/Video@name": "视频", + "Lily/lmsutils@name": "Lily 的工具箱", + "NOname-awa/graphics2d@name": "图形 2D", + "NexusKitten/controlcontrols@name": "控件控制", + "NexusKitten/moremotion@name": "更多运动", + "Skyhigh173/json@description": "处理JSON字符串和数组", + "box2d@name": "Box2D 物理引擎", + "clipboard@name": "剪切板", + "encoding@name": "编码", + "files@name": "文件", + "lab/text@name": "动画文字", + "obviousAlexC/SensingPlus@name": "侦测+", + "obviousAlexC/penPlus@name": "画笔+ V6", + "penplus@name": "画笔+ V5(旧)", + "qxsck/data-analysis@name": "数据分析", + "qxsck/var-and-list@name": "变量与列表", + "runtime-options@name": "运行选项", + "sound@name": "声音
", + "stretch@name": "伸缩
", + "text@name": "文本", + "true-fantom/base@name": "进制转换", + "true-fantom/math@name": "数学", + "true-fantom/network@name": "网络
", + "true-fantom/regexp@name": "正则表达式", + "utilities@name": "工具", + "veggiecan/LongmanDictionary@name": "朗文辞典", + "veggiecan/browserfullscreen@name": "全荧幕" + }, + "zh-tw": { + "runtime-options@name": "運行選項" + } +} \ No newline at end of file diff --git a/translations/extension-runtime.json b/translations/extension-runtime.json new file mode 100644 index 0000000000..fae2c56f04 --- /dev/null +++ b/translations/extension-runtime.json @@ -0,0 +1,90 @@ +{ + "es": { + "0832/rxFS2@del": "Eliminar [STR]", + "0832/rxFS2@folder": "Fijar [STR] a [STR2]", + "0832/rxFS2@folder_default": "¡rxFS es bueno!", + "0832/rxFS2@open": "Abrir [STR]", + "0832/rxFS2@start": "Crear [STR]", + "0832/rxFS2@sync": "Cambiar la ubicación de [STR] a [STR2]", + "0832/rxFS2@webin": "Cargar [STR] de la web", + "NOname-awa/graphics2d@area": "área", + "NOname-awa/graphics2d@diameter": "diámetro", + "NOname-awa/graphics2d@name": "Gráficos 2D", + "NOname-awa/graphics2d@radius": "radio" + }, + "it": { + "0832/rxFS2@clean": "Svuota il file system", + "0832/rxFS2@del": "Rimuovi [STR]", + "0832/rxFS2@folder": "Imposta [STR] a [STR2]", + "0832/rxFS2@folder_default": "rxFS funziona!", + "0832/rxFS2@in": "Importa il file system da [STR]", + "0832/rxFS2@list": "Elenco dei file in [STR]", + "0832/rxFS2@open": "Apri [STR]", + "0832/rxFS2@out": "Esporta il file system", + "0832/rxFS2@search": "Cerca [STR]", + "0832/rxFS2@start": "Crea [STR]", + "0832/rxFS2@sync": "Cambia la posizione di [STR] in [STR2]", + "0832/rxFS2@webin": "Leggi [STR] dal web", + "NOname-awa/graphics2d@circumference": "circonferenza", + "NOname-awa/graphics2d@diameter": "diametro", + "NOname-awa/graphics2d@graph": "[CS] del grafo [graph]", + "NOname-awa/graphics2d@line_section": "distanza tra ([x1],[y1]) e ([x2],[y2])", + "NOname-awa/graphics2d@name": "Grafica 2D", + "NOname-awa/graphics2d@pi": "pi greco", + "NOname-awa/graphics2d@quadrilateral": "[CS] del quadrangolo ([x1],[y1]) ([x2],[y2]) ([x3],[y3]) ([x4],[y4])", + "NOname-awa/graphics2d@radius": "raggio", + "NOname-awa/graphics2d@ray_direction": "direzione da ([x1],[y1]) a ([x2],[y2])", + "NOname-awa/graphics2d@round": "[CS] del cerchio [rd][a]", + "NOname-awa/graphics2d@triangle": "[CS] del triangolo ([x1],[y1]) ([x2],[y2]) ([x3],[y3])", + "NOname-awa/graphics2d@triangle_s": "area del triangolo [s1] [s2] [s3]", + "qxsck/data-analysis@average": "media di [NUMBERS]", + "qxsck/data-analysis@maximum": "massimo di [NUMBERS]", + "qxsck/data-analysis@median": "mediana di [NUMBERS]", + "qxsck/data-analysis@minimum": "minimo di [NUMBERS]", + "qxsck/data-analysis@mode": "moda di [NUMBERS]", + "qxsck/data-analysis@name": "Analisi dei Dati", + "qxsck/data-analysis@variance": "varianza di [NUMBERS]", + "qxsck/var-and-list@addValueInList": "aggiungi [VALUE] a [LIST]", + "qxsck/var-and-list@clearList": "cancella tutto da lista [LIST]", + "qxsck/var-and-list@copyList": "copia [LIST1] in [LIST2]", + "qxsck/var-and-list@deleteOfList": "cancella [INDEX] da [LIST]", + "qxsck/var-and-list@getIndexOfList": "prima occorrenza di [VALUE] in [LIST]", + "qxsck/var-and-list@getIndexesOfList": "occorrenze di [VALUE] in [LIST]", + "qxsck/var-and-list@getList": "valore di [LIST]", + "qxsck/var-and-list@getValueOfList": "elemento [INDEX] di [LIST]", + "qxsck/var-and-list@getVar": "valore di [VAR]", + "qxsck/var-and-list@length": "lunghezza di [LIST]", + "qxsck/var-and-list@listContains": "[LIST] contiene [VALUE]", + "qxsck/var-and-list@name": "Variabili e liste", + "qxsck/var-and-list@replaceOfList": "sostituisci elemento [INDEX] di [LIST] con [VALUE]", + "qxsck/var-and-list@seriListsToJson": "converti in json tutte le liste che iniziano con [START] ", + "qxsck/var-and-list@seriVarsToJson": "converti in json tutte le variabili che iniziano con [START]", + "qxsck/var-and-list@setVar": "porta il valore di [VAR] a [VALUE]" + }, + "zh-cn": { + "0832/rxFS2@clean": "清空文件系统", + "0832/rxFS2@del": "删除 [STR]", + "0832/rxFS2@folder": "设置 [STR] 为 [STR2]", + "0832/rxFS2@folder_default": "rxFS 好用!", + "0832/rxFS2@in": "从 [STR] 导入文件系统", + "0832/rxFS2@list": "列出 [STR] 下的所有文件", + "0832/rxFS2@open": "打开 [STR]", + "0832/rxFS2@out": "导出文件系统", + "0832/rxFS2@search": "搜索 [STR]", + "0832/rxFS2@start": "新建 [STR]", + "0832/rxFS2@sync": "将 [STR] 的位置改为 [STR2]", + "0832/rxFS2@webin": "从网络加载 [STR]", + "NOname-awa/graphics2d@graph": "图形 [graph] 的 [CS]", + "NOname-awa/graphics2d@line_section": "([x1],[y1])到([x2],[y2])的长度", + "NOname-awa/graphics2d@name": "图形 2D", + "NOname-awa/graphics2d@pi": "派", + "NOname-awa/graphics2d@quadrilateral": "矩形([x1],[y1])([x2],[y2])([x3],[y3])([x4],[y4])的 [CS]", + "NOname-awa/graphics2d@ray_direction": "([x1],[y1])的([x2],[y2])的距离", + "NOname-awa/graphics2d@round": "[rd] 为 [a] 的圆的 [CS]", + "NOname-awa/graphics2d@triangle": "三角形([x1],[y1])([x2],[y2])([x3],[y3])的 [CS]", + "NOname-awa/graphics2d@triangle_s": "三角形 [s1] [s2] [s3] 的面积", + "qxsck/data-analysis@name": "数据分析", + "qxsck/var-and-list@copyList": "复制 [LIST1] 到 [LIST2]", + "qxsck/var-and-list@name": "变量与列表" + } +} \ No newline at end of file