i18n feedback refactorings (#2597)

*  Create endpoint for node credential translation

*  Add API helper method in FE

* 🔨 Add creds JSON files to tsconfig

*  Refactor credentials loading

*  Refactor calls in CredentialConfig

* ✏️ Add dummy translations

*  Split translations per node

* 🔥 Remove deprecated method

*  Refactor nesting in collections

* 🚚 Rename topParameter methods for accuracy

* ✏️ Fill out GitHub dummy cred

* 🚚 Clarify naming for collection utils

* ✏️ Fill out dummy translation

* 🔥 Remove surplus colons

* 🔥 Remove logging

*  Restore missing space

* 🔥 Remove lingering colon

*  Add path to InputLabel calls

* ✏️ Fill out dummy translations

* 🐛 Fix multipleValuesButtonText logic

*  Add sample properties to be deleted

*  Render deeply nested params

* 📦 Update package-lock.json

* 🔥 remove logging

* ✏️ Add dummy value to Slack translation

* ✏️ Add placeholder to dummy translation

*  Fix placeholder rendering for button text

* 👕 Fix lint

* 🔥 Remove outdated comment

* 🐛 Pass in missing arg for placeholder

* ✏️ Fill out Slack translation

*  Add explanatory comment

* ✏️ Fill out dummy translation

* ✏️ Update documentation

* 🔥 Remove broken link

* ✏️ Add pending functionality

* ✏️ Fix indentation

* 🐛 Fix method call in CredentialEdit

*  Implement eventTriggerDescription

* 🐛 Fix table-json-binary radio buttons

* ✏️ Clarify usage of eventTriggerDescription

* 🔥 Remove unneeded arg

* 🐛 Fix display in CodeEdit and TextEdit

* 🔥 Remove logging

* ✏️ Add translation for test cred options

* ✏️ Add test for separate file in same dir

* ✏️ Add test for versioned node

* ✏️ Add test for node in grouped dir

* ✏️ Add minor clarifications

* ✏️ Add nested collection test

* ✏️ Add pending functionality

*  Generalize collections handling

* 🚚 Rename helper to remove redundancy

* 🚚 Improve naming in helpers

* ✏️ Improve helpers documentation

* ✏️ Improve i18n methods documentation

* 🚚 Make endpoint naming consistent

* ✏️ Add final newlines

* ✏️ Clean up JSON examples

*  Reuse i18n method

*  Improve utils readability

*  Return early if cred translation exists

* 🔥 Remove dummy translations
This commit is contained in:
Iván Ovejero 2022-01-07 22:02:21 +01:00 committed by GitHub
parent 6a2db6d107
commit 5fec563c5c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 920 additions and 634 deletions

236
package-lock.json generated
View file

@ -13560,6 +13560,15 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz",
"integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw=="
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"optional": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"array-union": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz",
@ -13595,6 +13604,21 @@
}
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"optional": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"optional": true
},
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@ -13657,6 +13681,58 @@
"worker-rpc": "^0.1.0"
}
},
"fork-ts-checker-webpack-plugin-v5": {
"version": "npm:fork-ts-checker-webpack-plugin@5.2.1",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.1.tgz",
"integrity": "sha512-SVi+ZAQOGbtAsUWrZvGzz38ga2YqjWvca1pXQFUArIVXqli0lLoDQ8uS0wg0kSpcwpZmaW5jVCZXQebkyUQSsw==",
"optional": true,
"requires": {
"@babel/code-frame": "^7.8.3",
"@types/json-schema": "^7.0.5",
"chalk": "^4.1.0",
"cosmiconfig": "^6.0.0",
"deepmerge": "^4.2.2",
"fs-extra": "^9.0.0",
"memfs": "^3.1.2",
"minimatch": "^3.0.4",
"schema-utils": "2.7.0",
"semver": "^7.3.2",
"tapable": "^1.0.0"
},
"dependencies": {
"chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"optional": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"semver": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
"optional": true,
"requires": {
"lru-cache": "^6.0.0"
}
}
}
},
"fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
"integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
"optional": true,
"requires": {
"at-least-node": "^1.0.0",
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
}
},
"glob-parent": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz",
@ -13691,6 +13767,12 @@
"slash": "^2.0.0"
}
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"optional": true
},
"ignore": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
@ -13719,6 +13801,16 @@
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
},
"jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"optional": true,
"requires": {
"graceful-fs": "^4.1.6",
"universalify": "^2.0.0"
}
},
"micromatch": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
@ -13754,6 +13846,17 @@
}
}
},
"schema-utils": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
"integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
"optional": true,
"requires": {
"@types/json-schema": "^7.0.4",
"ajv": "^6.12.2",
"ajv-keywords": "^3.4.1"
}
},
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
@ -13764,6 +13867,15 @@
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"optional": true,
"requires": {
"has-flag": "^4.0.0"
}
},
"to-regex-range": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
@ -13857,6 +13969,12 @@
"requires": {
"tslib": "^1.8.1"
}
},
"universalify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
"integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
"optional": true
}
}
},
@ -23356,124 +23474,6 @@
}
}
},
"fork-ts-checker-webpack-plugin-v5": {
"version": "npm:fork-ts-checker-webpack-plugin@5.2.1",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.1.tgz",
"integrity": "sha512-SVi+ZAQOGbtAsUWrZvGzz38ga2YqjWvca1pXQFUArIVXqli0lLoDQ8uS0wg0kSpcwpZmaW5jVCZXQebkyUQSsw==",
"optional": true,
"requires": {
"@babel/code-frame": "^7.8.3",
"@types/json-schema": "^7.0.5",
"chalk": "^4.1.0",
"cosmiconfig": "^6.0.0",
"deepmerge": "^4.2.2",
"fs-extra": "^9.0.0",
"memfs": "^3.1.2",
"minimatch": "^3.0.4",
"schema-utils": "2.7.0",
"semver": "^7.3.2",
"tapable": "^1.0.0"
},
"dependencies": {
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"optional": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"optional": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"optional": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"optional": true
},
"fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
"integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
"optional": true,
"requires": {
"at-least-node": "^1.0.0",
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
}
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"optional": true
},
"jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"optional": true,
"requires": {
"graceful-fs": "^4.1.6",
"universalify": "^2.0.0"
}
},
"schema-utils": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
"integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
"optional": true,
"requires": {
"@types/json-schema": "^7.0.4",
"ajv": "^6.12.2",
"ajv-keywords": "^3.4.1"
}
},
"semver": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
"optional": true,
"requires": {
"lru-cache": "^6.0.0"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"optional": true,
"requires": {
"has-flag": "^4.0.0"
}
},
"universalify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
"integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
"optional": true
}
}
},
"form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",

View file

@ -150,7 +150,7 @@ import { InternalHooksManager } from './InternalHooksManager';
import { TagEntity } from './databases/entities/TagEntity';
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
import { NameRequest } from './WorkflowHelpers';
import { getNodeTranslationPath } from './TranslationHelpers';
import { getCredentialTranslationPath, getNodeTranslationPath } from './TranslationHelpers';
require('body-parser-xml')(bodyParser);
@ -1178,6 +1178,27 @@ class App {
),
);
this.app.get(
`/${this.restEndpoint}/credential-translation`,
ResponseHelper.send(
async (
req: express.Request & { query: { credentialType: string } },
res: express.Response,
): Promise<object | null> => {
const translationPath = getCredentialTranslationPath({
locale: this.frontendSettings.defaultLocale,
credentialType: req.query.credentialType,
});
try {
return require(translationPath);
} catch (error) {
return null;
}
},
),
);
// Returns node information based on node names and versions
this.app.post(
`/${this.restEndpoint}/node-types`,
@ -1201,13 +1222,17 @@ class App {
nodeTypes: INodeTypeDescription[],
) {
const { description, sourcePath } = NodeTypes().getWithSourcePath(name, version);
const translationPath = await getNodeTranslationPath(sourcePath, defaultLocale);
const translationPath = await getNodeTranslationPath({
nodeSourcePath: sourcePath,
longNodeType: description.name,
locale: defaultLocale,
});
try {
const translation = await readFile(translationPath, 'utf8');
description.translation = JSON.parse(translation);
} catch (error) {
// ignore - no translation at expected translation path
// ignore - no translation exists at path
}
nodeTypes.push(description);

View file

@ -2,7 +2,7 @@ import { join, dirname } from 'path';
import { readdir } from 'fs/promises';
import { Dirent } from 'fs';
const ALLOWED_VERSIONED_DIRNAME_LENGTH = [2, 3]; // v1, v10
const ALLOWED_VERSIONED_DIRNAME_LENGTH = [2, 3]; // e.g. v1, v10
function isVersionedDirname(dirent: Dirent) {
if (!dirent.isDirectory()) return false;
@ -26,14 +26,39 @@ async function getMaxVersion(from: string) {
return Math.max(...dirnames.map((d) => parseInt(d.charAt(1), 10)));
}
export async function getNodeTranslationPath(
nodeSourcePath: string,
language: string,
): Promise<string> {
/**
* Get the full path to a node translation file in `/dist`.
*/
export async function getNodeTranslationPath({
nodeSourcePath,
longNodeType,
locale,
}: {
nodeSourcePath: string;
longNodeType: string;
locale: string;
}): Promise<string> {
const nodeDir = dirname(nodeSourcePath);
const maxVersion = await getMaxVersion(nodeDir);
const nodeType = longNodeType.replace('n8n-nodes-base.', '');
return maxVersion
? join(nodeDir, `v${maxVersion}`, 'translations', `${language}.json`)
: join(nodeDir, 'translations', `${language}.json`);
? join(nodeDir, `v${maxVersion}`, 'translations', locale, `${nodeType}.json`)
: join(nodeDir, 'translations', locale, `${nodeType}.json`);
}
/**
* Get the full path to a credential translation file in `/dist`.
*/
export function getCredentialTranslationPath({
locale,
credentialType,
}: {
locale: string;
credentialType: string;
}): string {
const packagesPath = join(__dirname, '..', '..', '..');
const credsPath = join(packagesPath, 'nodes-base', 'dist', 'credentials');
return join(credsPath, 'translations', locale, `${credentialType}.json`);
}

View file

@ -163,6 +163,7 @@ export interface IRestApi {
getPastExecutions(filter: object, limit: number, lastId?: string | number, firstId?: string | number): Promise<IExecutionsListResponse>;
stopCurrentExecution(executionId: string): Promise<IExecutionsStopData>;
makeRestApiRequest(method: string, endpoint: string, data?: any): Promise<any>; // tslint:disable-line:no-any
getCredentialTranslation(credentialType: string): Promise<object>;
getNodeTranslationHeaders(): Promise<INodeTranslationHeaders>;
getNodeTypes(onlyLatest?: boolean): Promise<INodeTypeDescription[]>;
getNodesInformation(nodeInfos: INodeTypeNameVersion[]): Promise<INodeTypeDescription[]>;
@ -646,9 +647,9 @@ export interface IRootState {
activeExecutions: IExecutionsCurrentSummaryExtended[];
activeWorkflows: string[];
activeActions: string[];
activeCredentialType: string | null;
activeNode: string | null;
baseUrl: string;
credentialTextRenderKeys: { nodeType: string; credentialType: string; } | null;
defaultLocale: string;
endpointWebhook: string;
endpointWebhookTest: string;

View file

@ -4,7 +4,7 @@
append-to-body
:close-on-click-modal="false"
width="80%"
:title="`${$locale.baseText('codeEdit.edit')} ${$locale.nodeText().topParameterDisplayName(parameter)}`"
:title="`${$locale.baseText('codeEdit.edit')} ${$locale.nodeText().inputLabelDisplayName(parameter, path)}`"
:before-close="closeDialog"
>
<div class="text-editor-wrapper ignore-key-press">
@ -39,7 +39,7 @@ export default mixins(
workflowHelpers,
).extend({
name: 'CodeEdit',
props: ['codeAutocomplete', 'parameter', 'type', 'value'],
props: ['codeAutocomplete', 'parameter', 'path', 'type', 'value'],
data() {
return {
monacoInstance: null as monaco.editor.IStandaloneCodeEditor | null,

View file

@ -19,7 +19,7 @@
<n8n-option
v-for="item in parameterOptions"
:key="item.name"
:label="$locale.nodeText().collectionOptionDisplayName(parameter, item)"
:label="$locale.nodeText().collectionOptionDisplayName(parameter, item, path)"
:value="item.name">
</n8n-option>
</n8n-select>
@ -67,7 +67,7 @@ export default mixins(
},
computed: {
getPlaceholderText (): string {
const placeholder = this.$locale.nodeText().placeholder(this.parameter);
const placeholder = this.$locale.nodeText().placeholder(this.parameter, this.path);
return placeholder ? placeholder : this.$locale.baseText('collectionParameter.choose');
},
getProperties (): INodeProperties[] {

View file

@ -38,7 +38,7 @@ export default mixins(copyPaste, showMessage).extend({
this.copyToClipboard(this.$props.copyContent);
this.$showMessage({
title: this.$locale.baseText('credentialsEdit.showMessage.title'),
title: this.$locale.baseText('credentialEdit.credentialEdit.showMessage.title'),
message: this.$props.successMessage,
type: 'success',
});

View file

@ -81,7 +81,7 @@ import CopyInput from '../CopyInput.vue';
import CredentialInputs from './CredentialInputs.vue';
import OauthButton from './OauthButton.vue';
import { restApi } from '@/components/mixins/restApi';
import { addNodeTranslation } from '@/plugins/i18n';
import { addCredentialTranslation } from '@/plugins/i18n';
import mixins from 'vue-typed-mixins';
export default mixins(restApi).extend({
@ -128,10 +128,20 @@ export default mixins(restApi).extend({
},
},
async beforeMount() {
if (this.$store.getters.defaultLocale !== 'en') {
await this.findCredentialTextRenderKeys();
await this.addNodeTranslationForCredential();
}
if (this.$store.getters.defaultLocale === 'en') return;
this.$store.commit('setActiveCredentialType', this.credentialType.name);
const key = `n8n-nodes-base.credentials.${this.credentialType.name}`;
if (this.$locale.exists(key)) return;
const credTranslation = await this.restApi().getCredentialTranslation(this.credentialType.name);
addCredentialTranslation(
{ [this.credentialType.name]: credTranslation },
this.$store.getters.defaultLocale,
);
},
computed: {
appName(): string {
@ -139,6 +149,8 @@ export default mixins(restApi).extend({
return '';
}
const appName = getAppNameFromCredType(
(this.credentialType as ICredentialType).displayName,
);
@ -177,47 +189,6 @@ export default mixins(restApi).extend({
},
},
methods: {
/**
* Find the keys needed by the mixin to render credential text, and place them in the Vuex store.
*/
async findCredentialTextRenderKeys() {
const nodeTypes = await this.restApi().getNodeTypes();
// credential type name node type name
const map = nodeTypes.reduce<Record<string, string>>((acc, cur) => {
if (!cur.credentials) return acc;
cur.credentials.forEach(cred => {
if (acc[cred.name]) return;
acc[cred.name] = cur.name;
});
return acc;
}, {});
const renderKeys = {
nodeType: map[this.credentialType.name],
credentialType: this.credentialType.name,
};
this.$store.commit('setCredentialTextRenderKeys', renderKeys);
},
/**
* Add to the translation object the node translation for the credential in the modal.
*/
async addNodeTranslationForCredential() {
const { nodeType }: { nodeType: string } = this.$store.getters.credentialTextRenderKeys;
const version = await this.getCurrentNodeVersion(nodeType);
const nodeToBeFetched = [{ name: nodeType, version }];
const nodesInfo = await this.restApi().getNodesInformation(nodeToBeFetched);
const nodeInfo = nodesInfo.pop();
if (nodeInfo && nodeInfo.translation) {
addNodeTranslation(nodeInfo.translation, this.$store.getters.defaultLocale);
}
},
/**
* Get the current version for a node type.
*/

View file

@ -1,7 +1,7 @@
<template>
<div>
<div class="error-header">
<div class="error-message">{{ $locale.baseText('nodeErrorView.error') + ':' + error.message }}</div>
<div class="error-message">{{ $locale.baseText('nodeErrorView.error') + ': ' + error.message }}</div>
<div class="error-description" v-if="error.description">{{error.description}}</div>
</div>
<details>

View file

@ -647,7 +647,7 @@ export default mixins(
this.$showError(
error,
this.$locale.baseText('executionsList.showError.retryExecution.title'),
this.$locale.baseText('executionsList.showError.retryExecution.message') + ':',
this.$locale.baseText('executionsList.showError.retryExecution.message'),
);
this.isDataLoading = false;

View file

@ -6,7 +6,7 @@
<div v-for="property in getProperties" :key="property.name" class="fixed-collection-parameter-property">
<n8n-input-label
:label="property.displayName === '' || parameter.options.length === 1 ? '' : $locale.nodeText().topParameterDisplayName(property)"
:label="property.displayName === '' || parameter.options.length === 1 ? '' : $locale.nodeText().inputLabelDisplayName(property, path)"
:underline="true"
:labelHoverableOnly="true"
size="small"
@ -43,7 +43,7 @@
<n8n-option
v-for="item in parameterOptions"
:key="item.name"
:label="$locale.nodeText().collectionOptionDisplayName(parameter, item)"
:label="$locale.nodeText().collectionOptionDisplayName(parameter, item, path)"
:value="item.name">
</n8n-option>
</n8n-select>
@ -85,7 +85,7 @@ export default mixins(genericHelpers)
},
computed: {
getPlaceholderText (): string {
const placeholder = this.$locale.nodeText().placeholder(this.parameter);
const placeholder = this.$locale.nodeText().placeholder(this.parameter, this.path);
return placeholder ? placeholder : this.$locale.baseText('fixedCollectionParameter.choose');
},
getProperties (): INodePropertyCollection[] {

View file

@ -1,8 +1,8 @@
<template>
<div @keydown.stop class="duplicate-parameter">
<n8n-input-label
:label="$locale.nodeText().topParameterDisplayName(parameter)"
:tooltipText="$locale.nodeText().topParameterDescription(parameter)"
:label="$locale.nodeText().inputLabelDisplayName(parameter, path)"
:tooltipText="$locale.nodeText().inputLabelDescription(parameter, path)"
:underline="true"
:labelHoverableOnly="true"
size="small"

View file

@ -123,9 +123,18 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
},
getTriggerNodeTooltip (): string | undefined {
if (this.nodeType !== null && this.nodeType.hasOwnProperty('eventTriggerDescription')) {
return this.nodeType.eventTriggerDescription;
const nodeName = this.$locale.shortNodeType(this.nodeType.name);
const { eventTriggerDescription } = this.nodeType;
return this.$locale.nodeText().eventTriggerDescription(nodeName, eventTriggerDescription);
} else {
return `Waiting for you to create an event in ${this.nodeType && this.nodeType.displayName.replace(/Trigger/, "")}`;
return this.$locale.baseText(
'node.waitingForYouToCreateAnEventIn',
{
interpolate: {
nodeType: this.nodeType && this.nodeType.displayName.replace(/Trigger/, ""),
},
},
);
}
},
isPollingTypeNode (): boolean {

View file

@ -13,8 +13,8 @@
/>
<div v-else-if="['json', 'string'].includes(parameter.type) || remoteParameterOptionsLoadingIssues !== null">
<code-edit v-if="codeEditDialogVisible" :value="value" :parameter="parameter" :type="editorType" :codeAutocomplete="codeAutocomplete" @closeDialog="closeCodeEditDialog" @valueChanged="expressionUpdated"></code-edit>
<text-edit :dialogVisible="textEditDialogVisible" :value="value" :parameter="parameter" @closeDialog="closeTextEditDialog" @valueChanged="expressionUpdated"></text-edit>
<code-edit v-if="codeEditDialogVisible" :value="value" :parameter="parameter" :type="editorType" :codeAutocomplete="codeAutocomplete" :path="path" @closeDialog="closeCodeEditDialog" @valueChanged="expressionUpdated"></code-edit>
<text-edit :dialogVisible="textEditDialogVisible" :value="value" :parameter="parameter" :path="path" @closeDialog="closeTextEditDialog" @valueChanged="expressionUpdated"></text-edit>
<div v-if="isEditor === true" class="code-edit clickable" @click="displayEditDialog()">
<prism-editor v-if="!codeEditDialogVisible" :lineNumbers="true" :readonly="true" :code="displayValue" language="js"></prism-editor>
@ -576,17 +576,17 @@ export default mixins(
getPlaceholder(): string {
return this.isForCredential
? this.$locale.credText().placeholder(this.parameter)
: this.$locale.nodeText().placeholder(this.parameter);
: this.$locale.nodeText().placeholder(this.parameter, this.path);
},
getOptionsOptionDisplayName(option: { value: string; name: string }): string {
return this.isForCredential
? this.$locale.credText().optionsOptionDisplayName(this.parameter, option)
: this.$locale.nodeText().optionsOptionDisplayName(this.parameter, option);
: this.$locale.nodeText().optionsOptionDisplayName(this.parameter, option, this.path);
},
getOptionsOptionDescription(option: { value: string; description: string }): string {
return this.isForCredential
? this.$locale.credText().optionsOptionDescription(this.parameter, option)
: this.$locale.nodeText().optionsOptionDescription(this.parameter, option);
: this.$locale.nodeText().optionsOptionDescription(this.parameter, option, this.path);
},
async loadRemoteParameterOptions () {

View file

@ -1,7 +1,7 @@
<template>
<n8n-input-label
:label="$locale.credText().topParameterDisplayName(parameter)"
:tooltipText="$locale.credText().topParameterDescription(parameter)"
:label="$locale.credText().inputLabelDisplayName(parameter)"
:tooltipText="$locale.credText().inputLabelDescription(parameter)"
:required="parameter.required"
:showTooltip="focused"
>

View file

@ -1,7 +1,7 @@
<template>
<n8n-input-label
:label="$locale.nodeText().topParameterDisplayName(parameter)"
:tooltipText="$locale.nodeText().topParameterDescription(parameter)"
:label="$locale.nodeText().inputLabelDisplayName(parameter, path)"
:tooltipText="$locale.nodeText().inputLabelDescription(parameter, path)"
:showTooltip="focused"
:bold="false"
size="small"

View file

@ -16,7 +16,7 @@
<div v-else-if="parameter.type === 'notice'" class="parameter-item parameter-notice">
<n8n-text size="small">
<span v-html="$locale.nodeText().topParameterDisplayName(parameter)"></span>
<span v-html="$locale.nodeText().inputLabelDisplayName(parameter, path)"></span>
</n8n-text>
</div>
@ -33,8 +33,8 @@
/>
</div>
<n8n-input-label
:label="$locale.nodeText().topParameterDisplayName(parameter)"
:tooltipText="$locale.nodeText().topParameterDescription(parameter)"
:label="$locale.nodeText().inputLabelDisplayName(parameter, path)"
:tooltipText="$locale.nodeText().inputLabelDescription(parameter, path)"
size="small"
:underline="true"
:labelHoverableOnly="true"

View file

@ -67,7 +67,7 @@
<el-radio-button :label="$locale.baseText('runData.binary')" v-if="binaryData.length !== 0"></el-radio-button>
</el-radio-group>
</div>
<div v-if="hasNodeRun && !hasRunError && displayMode === 'JSON' && state.path !== deselectedPlaceholder" class="select-button">
<div v-if="hasNodeRun && !hasRunError && displayMode === $locale.baseText('runData.json') && state.path !== deselectedPlaceholder" class="select-button">
<el-dropdown trigger="click" @command="handleCopyClick">
<span class="el-dropdown-link">
<n8n-icon-button :title="$locale.baseText('runData.copyToClipboard')" icon="copy" />
@ -111,14 +111,14 @@
<n8n-button
icon="eye"
:label="$locale.baseText('runData.displayDataAnyway')"
@click="displayMode = 'Table';showData = true;"
@click="displayMode = $locale.baseText('runData.table');showData = true;"
/>
</div>
<div v-else-if="['JSON', 'Table'].includes(displayMode)">
<div v-else-if="[$locale.baseText('runData.json'), $locale.baseText('runData.table')].includes(displayMode)">
<div v-if="jsonData.length === 0" class="no-data">
{{ $locale.baseText('runData.noTextDataFound') }}
</div>
<div v-else-if="displayMode === 'Table'">
<div v-else-if="displayMode === $locale.baseText('runData.table')">
<div v-if="tableData !== null && tableData.columns.length === 0" class="no-data">
{{ $locale.baseText('runData.entriesExistButThey') }}
</div>
@ -132,7 +132,7 @@
</table>
</div>
<vue-json-pretty
v-else-if="displayMode === 'JSON'"
v-else-if="displayMode === $locale.baseText('runData.json')"
:data="jsonData"
:deep="10"
v-model="state.path"
@ -146,7 +146,7 @@
class="json-data"
/>
</div>
<div v-else-if="displayMode === 'Binary'">
<div v-else-if="displayMode === $locale.baseText('runData.binary')">
<div v-if="binaryData.length === 0" class="no-data">
{{ $locale.baseText('runData.noBinaryDataFound') }}
</div>
@ -264,7 +264,7 @@ export default mixins(
binaryDataPreviewActive: false,
dataSize: 0,
deselectedPlaceholder,
displayMode: 'Table',
displayMode: this.$locale.baseText('runData.table'),
state: {
value: '' as object | number | string,
path: deselectedPlaceholder,
@ -441,10 +441,10 @@ export default mixins(
this.outputIndex = 0;
this.maxDisplayItems = 25;
this.refreshDataSize();
if (this.displayMode === 'Binary') {
if (this.displayMode === this.$locale.baseText('runData.binary')) {
this.closeBinaryDataDisplay();
if (this.binaryData.length === 0) {
this.displayMode = 'Table';
this.displayMode = this.$locale.baseText('runData.table');
}
}
},

View file

@ -1,11 +1,11 @@
<template>
<div v-if="dialogVisible">
<el-dialog :visible="dialogVisible" append-to-body width="80%" :title="`${$locale.baseText('textEdit.edit')} ${$locale.nodeText().topParameterDisplayName(parameter)}`" :before-close="closeDialog">
<el-dialog :visible="dialogVisible" append-to-body width="80%" :title="`${$locale.baseText('textEdit.edit')} ${$locale.nodeText().inputLabelDisplayName(parameter, path)}`" :before-close="closeDialog">
<div class="ignore-key-press">
<n8n-input-label :label="$locale.nodeText().topParameterDisplayName(parameter)">
<n8n-input-label :label="$locale.nodeText().inputLabelDisplayName(parameter, path)">
<div @keydown.stop @keydown.esc="closeDialog()">
<n8n-input v-model="tempValue" type="textarea" ref="inputField" :value="value" :placeholder="$locale.nodeText().placeholder(parameter)" @change="valueChanged" @keydown.stop="noOp" :rows="15" />
<n8n-input v-model="tempValue" type="textarea" ref="inputField" :value="value" :placeholder="$locale.nodeText().placeholder(parameter, path)" @change="valueChanged" @keydown.stop="noOp" :rows="15" />
</div>
</n8n-input-label>
</div>
@ -22,6 +22,7 @@ export default Vue.extend({
props: [
'dialogVisible',
'parameter',
'path',
'value',
],
data () {

View file

@ -79,6 +79,10 @@ export const restApi = Vue.extend({
return self.restApi().makeRestApiRequest('POST', `/executions-current/${executionId}/stop`);
},
getCredentialTranslation: (credentialType): Promise<object> => {
return self.restApi().makeRestApiRequest('GET', '/credential-translation', { credentialType });
},
getNodeTranslationHeaders: (): Promise<INodeTranslationHeaders> => {
return self.restApi().makeRestApiRequest('GET', '/node-translation-headers');
},

View file

@ -479,7 +479,7 @@ export const workflowHelpers = mixins(
this.$showMessage({
title: this.$locale.baseText('workflowHelpers.showMessage.title'),
message: this.$locale.baseText('workflowHelpers.showMessage.message') + `: "${e.message}"`,
message: this.$locale.baseText('workflowHelpers.showMessage.message') + `"${e.message}"`,
type: 'error',
});
@ -553,7 +553,7 @@ export const workflowHelpers = mixins(
this.$showMessage({
title: this.$locale.baseText('workflowHelpers.showMessage.title'),
message: this.$locale.baseText('workflowHelpers.showMessage.message') + `: "${e.message}"`,
message: this.$locale.baseText('workflowHelpers.showMessage.message') + `"${e.message}"`,
type: 'error',
});

View file

@ -0,0 +1,114 @@
# Addendum for i18n in n8n
## Base text
### Interpolation
Certain base text strings use [interpolation](https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting) to allow for a variable to be passed in, signalled by curly braces:
```json
{
"stopExecution": {
"message": "The execution with the ID {activeExecutionId} got stopped!",
"title": "Execution stopped"
}
}
```
When translating a string containing an interpolated variable, leave the variable untranslated:
```json
{
"stopExecution": {
"message": "Die Ausführung mit der ID {activeExecutionId} wurde gestoppt",
"title": "Execution stopped"
}
}
```
### Reusable base text
As a convenience, the base text file may contain the special key `reusableBaseText`, which defines strings that can be shared among other strings with the syntax `@:reusableBaseText.key`, as follows:
```json
{
"reusableBaseText": {
"save": "🇩🇪 Save",
},
"duplicateWorkflowDialog": {
"enterWorkflowName": "🇩🇪 Enter workflow name",
"save": "@:reusableBaseText.save",
},
"saveButton": {
"save": "@:reusableBaseText.save",
"saving": "🇩🇪 Saving",
"saved": "🇩🇪 Saved",
},
}
```
For more information, refer to Vue i18n's [linked locale messages](https://kazupon.github.io/vue-i18n/guide/messages.html#linked-locale-messages).
### Nodes in versioned dirs
For nodes in versioned dirs, place the `/translations` dir for the node translation file alongside the versioned `*.node.ts` file:
```
Mattermost
└── Mattermost.node.ts
└── v1
├── MattermostV1.node.ts
├── actions
├── methods
├── transport
└── translations
└── de
└── mattermost.json
```
### Nodes in grouping dirs
For nodes in grouping dirs, e.g. Google nodes, place the `/translations` dir for the node translation file alongside the `*.node.ts` file:
```
Google
├── Books
├── Calendar
└── Drive
├── GoogleDrive.node.ts
└── translations
└── de
├── googleDrive.json
└── googleDriveTrigger.json
```
## Dynamic text
### Reusable dynamic text
The base text file may contain the special key `reusableDynamicText`, allowing for a node parameter to be translated once and reused in all other node parameter translations.
Currently only the keys `oauth.clientId` and `oauth.clientSecret` are supported as a PoC - these two translations will be reused in all node credential parameters.
```json
{
"reusableDynamicText": {
"oauth2": {
"clientId": "🇩🇪 Client ID",
"clientSecret": "🇩🇪 Client Secret",
}
}
}
```
### Special cases
`eventTriggerDescription` is a dynamic node property that is not part of node parameters. To translate it, set the `eventTriggerDescription` key at the root level of the `nodeView` property in the node translation file.
```json
{
"nodeView": {
"eventTriggerDescription": "🇩🇪 Waiting for you to call the Test URL"
}
}
```

View file

@ -6,251 +6,150 @@ n8n allows for internalization of the majority of UI text:
- base text, e.g. menu display items in the left-hand sidebar menu,
- node text, e.g. parameter display names and placeholders in the node view,
- header text, e.g. node display names and descriptions in the nodes panel.
- header text, e.g. node display names and descriptions at various spots.
Currently, n8n does _not_ allow for internalization of:
- messages from outside the `editor-ui` package, e.g. `No active database connection`,
- strings in certain Vue components, e.g. date time picker
- node subtitles, e.g. `create: user` or `getAll: post` below the node name on the canvas,
- new version notification contents in the updates panel, e.g. `Includes node enhancements`.
- new version notification contents in the updates panel, e.g. `Includes node enhancements`, and
- options that rely on `loadOptionsMethod`.
Pending functionality:
- Search in nodes panel by translated node name
- UI responsiveness to differently sized strings
- Locale-aware number formatting
## Locale identifiers
A locale identifier is a language code compatible with the [`Accept-Language` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language), e.g. `de` (German), `es` (Spanish), `ja` (Japanese). Regional variants of locale identifiers are not supported, i.e. use `de`, not `de-AT`. For a list of all locale identifiers, refer to the [639-1 column in the ISO 639-1 codes article](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes).
A **locale identifier** is a language code compatible with the [`Accept-Language` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language), e.g. `de` (German), `es` (Spanish), `ja` (Japanese). Regional variants of locale identifiers, such as `-AT` in `de-AT`, are _not_ supported. For a list of all locale identifiers, see [column 639-1 in this table](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes).
By default, n8n runs in the `en` (English) locale. To have it run in a different locale, set the `N8N_DEFAULT_LOCALE` environment variable. If it has been set and is not `en`, n8n will use the UI strings for that locale - for any untranslated UI strings, n8n will automatically fall back to `en`.
By default, n8n runs in the `en` (English) locale. To have run it in a different locale, set the `N8N_DEFAULT_LOCALE` environment variable to a locale identifier. When running in a non-`en` locale, n8n will display UI strings for the selected locale and fall back to `en` for any untranslated strings.
```sh
```
export N8N_DEFAULT_LOCALE=de
npm run start
```
Output:
```
Initializing n8n process
n8n ready on 0.0.0.0, port 5678
Version: 0.156.0
Locale: de
Editor is now accessible via:
http://localhost:5678/
Press "o" to open in Browser.
```
## Base text
Base text is directly rendered with no dependencies. Base text is supplied by the user in one file per locale in the `/editor-ui` package.
Base text is rendered with no dependencies, i.e. base text is fixed and does not change in any circumstances. Base text is supplied by the user in one file per locale in the `/editor-ui` package.
### Locating base text
Each base text file is located at `/packages/editor-ui/src/plugins/i18n/locales/{localeIdentifier}.json` and exports an object where keys are Vue component names (and their containing dirs if any) and references to parts of those Vue components.
The base text file for each locale is located at `/packages/editor-ui/src/plugins/i18n/locales/` and is named `{localeIdentifier}.json`. Keys in the base text file can be Vue component dirs, Vue component names, and references to symbols in those Vue components. These keys are added by the team as the UI is modified or expanded.
```json
"nodeCreator": {
"categoryNames": {
"analytics": "🇩🇪 Analytics",
"communication": "🇩🇪 Communication",
"coreNodes": "🇩🇪 Core Nodes",
"coreNodes": "🇩🇪 Core Nodes"
}
}
```
### Translating base text
1. For the new locale identifier, e.g. `de`, copy the `en` base text and rename it:
1. Select a new locale identifier, e.g. `de`, copy the `en` JSON base text file with a new name:
```sh
```
cp ./packages/editor-ui/src/plugins/i18n/locales/en.json ./packages/editor-ui/src/plugins/i18n/locales/de.json
```
2. Check in the UI for a base text string to translate, and find it in the newly created base text file.
2. Find in the UI a string to translate, and search for it in the newly created base text file. Alternatively,find in `/editor-ui` a call to `$locale.baseText(key)`, e.g. `$locale.baseText('workflowActivator.deactivateWorkflow')`, and take note of the key and find it in the newly created base text file.
> **Note**: If you cannot find a string in the new base text file, either it does not belong to base text (i.e., the string might be part of header text, credential text, or node text), or the string might belong to the backend, where i18n is currently unsupported.
3. Translate the string value - do not change the key. In the examples below, a string starting with 🇩🇪 stands for a translated string.
3. Translate the string value - do not change the key. In the examples below, a string starting with 🇩🇪 stands for a string translated from English into German.
Optionally, remove any untranslated strings from the new base text file. Untranslated strings in the new base text file will automatically fall back to the `en` base text file.
As an optional final step, remove any untranslated strings from the new base text file. Untranslated strings in the new base text file will trigger a fallback to the `en` base text file.
#### Reusable base text
As a convenience, the base text file may contain the special key `reusableBaseText` to share strings between translations. For more information, refer to Vue i18n's [linked locale messages](https://kazupon.github.io/vue-i18n/guide/messages.html#linked-locale-messages).
```json
{
"reusableBaseText": {
"save": "🇩🇪 Save",
},
"duplicateWorkflowDialog": {
"enterWorkflowName": "🇩🇪 Enter workflow name",
"save": "@:reusableBaseText.save",
},
"saveButton": {
"save": "@:reusableBaseText.save",
"saving": "🇩🇪 Saving",
"saved": "🇩🇪 Saved",
},
```
<!--
As a convenience, the base text file may also contain the special key `numberFormats` to localize numbers. For more information, refer to Vue i18n's [number localization](https://kazupon.github.io/vue-i18n/guide/number.html#number-localization).
```json
{
"numberFormats": {
"decimal": {
"style": "decimal",
},
},
}
``` -->
#### Interpolation
Some base text strings use [interpolation](https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting) with a variable in curly braces, e.g. `Execution ID {activeExecutionId} was stopped`. In case of interpolation, the translated string must not modify the variable: `Die Ausführung mit der ID {activeExecutionId} wurde gestoppt`.
> For information about **interpolation** and **reusable base text**, refer to the [Addendum](./ADDENDUM.md).
## Dynamic text
Dynamic text is **text that relies on node-related data** in order to be rendered. Node-related data is supplied by the user in multiple files in the `/nodes-base` package. Dynamic text is mostly visible in the node view, i.e. the node on the canvas and the node parameters modal.
Dynamic text relies on data specific to each node and credential:
- `headerText` and `nodeText` in the **node translation file**
- `credText` in the **credential translation file**
### Locating dynamic text
Dynamic text is divided into files located in `/translations` dirs alongside the translated nodes:
#### Locating the credential translation file
A credential translation file is placed at `/nodes-base/credentials/translations/{localeIdentifier}`
```
GitHub
├── GitHub.node.ts
├── GitHubTrigger.node.ts
└── translations
├── de.json
├── es.json
└── ja.json
credentials
└── translations
└── de
├── githubApi.json
└── githubOAuth2Api.json
```
Every credential must have its own credential translation file.
Each node translation file may contain the translations for one or both (regular and trigger) nodes.
For nodes in grouping dirs, e.g. `Google`, `Aws`, and `Microsoft`, locate the `/translations` dir alongside the `*.node.ts` file:
```
Google
└── Drive
├── GoogleDrive.node.ts
└── translations
├── de.json
├── es.json
└── ja.json
```
For nodes in versioned dirs, locate the `/translations` dir alongside the versioned `*.node.ts` file:
```
Mattermost
└── Mattermost.node.ts
└── v1
├── MattermostV1.node.ts
├── actions
├── methods
├── transport
└── translations
├── de.json
├── es.json
└── ja.json
```
### Translating dynamic text
> **Note**: In the examples below, the node source is located at `/packages/nodes-base/nodes/Github/GitHub.node.ts` and the node translation is located at `/packages/nodes-base/nodes/Github/translations/de.json`.
Each node translation is an object with a key that matches the node's `description.name`:
```ts
export class Github implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub',
description: 'Consume GitHub API',
name: 'github', // key to use in translation
icon: 'file:github.svg',
group: ['input'],
version: 1,
```
```json
{
"github": {}, // key from node's description.name
"githubTrigger": {}, // key from node's description.name
}
```
The object inside allows for three keys: `header`, `credentialsModal` and `nodeView`. These are the _sections_ of each node translation:
```json
{
"github": {
"header": {},
"credentialsModal": {},
"nodeView": {},
},
"githubTrigger": {
"header": {},
"credentialsModal": {},
"nodeView": {},
},
}
```
> **Note**: These three keys as well as all keys described below are optional. Remember that, in case of missing sections or missing translations, n8n will fall back to the `en` locale.
#### `header` section
The `header` section points to an object that may contain only two keys, `displayName` and `description`, matching the node's `description.displayName` and `description.description`. These are used in the nodes panel, in the node view and in the node credentials modal.
```ts
export class Github implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub', // key to use in translation
description: 'Consume GitHub API', // key to use in translation
name: 'github',
icon: 'file:github.svg',
group: ['input'],
version: 1,
```
```json
{
"github": {
"header": {
"displayName": "🇩🇪 GitHub",
"description": "🇩🇪 Consume GitHub API",
},
},
}
```
Header text is used wherever the node's display name and description are needed:
<p align="center">
<img src="img/header1.png" width="400">
<img src="img/header2.png" width="200">
<img src="img/header3.png" width="400">
</p>
<p align="center">
<img src="img/header4.png" width="400">
<img src="img/header5.png" width="500">
</p>
#### `credentialsModal` section
> **Note**: In the examples below, the node credential source is located at `/packages/nodes-base/credentials/GithubApi.credentials.ts`.
The `credentialsModal` section points to an object containing a key that matches the node credential `name`.
The name of the credential translation file must be sourced from the credential's `description.name` property:
```ts
export class GithubApi implements ICredentialType {
name = 'githubApi'; // key to use in translation
name = 'githubApi'; // to use for credential translation file
displayName = 'Github API';
documentationUrl = 'github';
properties: INodeProperties[] = [
```
```json
{
"github": {
"header": {},
"credentialsModal": {
"githubApi": {} // key from node credential name
},
"nodeView": {},
},
}
#### Locating the node translation file
A node translation file is placed at `/nodes-base/nodes/{node}/translations/{localeIdentifier}`
```
GitHub
├── GitHub.node.ts
├── GitHubTrigger.node.ts
└── translations
└── de
├── github.json
└── githubTrigger.json
```
The node credential `name` key points to an object containing translation keys that match the node's credential parameter names:
Every node must have its own node translation file.
> For information about nodes in **versioned dirs** and **grouping dirs**, refer to the [Addendum](./ADDENDUM.md).
The name of the node translation file must be sourced from the node's `description.name` property:
```ts
export class Github implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub',
name: 'github', // to use for node translation file name
icon: 'file:github.svg',
group: ['input'],
```
### Translating dynamic text
#### Translating the credential translation file
> **Note**: All translation keys are optional. Missing translation values trigger a fallback to the `en` locale strings.
A credential translation file, e.g. `githubApi.json` is an object containing keys that match the credential parameter names:
```ts
export class GithubApi implements ICredentialType {
@ -283,17 +182,9 @@ export class GithubApi implements ICredentialType {
```json
{
"github": {
"header": {},
"credentialsModal": {
"githubApi": {
"server": {} // key from node credential parameter name
"user": {} // key from node credential parameter name
"accessToken": {} // key from node credential parameter name
},
},
"nodeView": {},
},
"server": {...},
"user": {...},
"accessToken": {...},
}
```
@ -301,34 +192,74 @@ The object for each node credential parameter allows for the keys `displayName`,
```json
{
"github": {
"header": {},
"credentialsModal": {
"githubApi": {
"server": {
"displayName": "🇩🇪 Github Server",
"description": "🇩🇪 The server to connect to. Only has to be set if Github Enterprise is used.",
},
"user": {
"placeholder": "🇩🇪 Hans",
},
"accessToken": {
"placeholder": "🇩🇪 123",
},
},
},
"nodeView": {},
"server": {
"displayName": "🇩🇪 Github Server",
"description": "🇩🇪 The server to connect to. Only has to be set if Github Enterprise is used.",
},
"user": {
"placeholder": "🇩🇪 Hans",
},
"accessToken": {
"placeholder": "🇩🇪 123",
},
}
```
<p align="center">
<img src="img/cred.png">
<img src="img/cred.png">
</p>
#### `nodeView` section
Only existing parameters are translatable. If a credential parameter does not have a description in the English original, adding a translation for that non-existing parameter will not result in the translation being displayed - the parameter will need to be added in the English original first.
The `nodeView` section points to an object containing translation keys that match the node's operational parameters.
#### Translating the node translation file
> **Note**: All keys are optional. Missing translations trigger a fallback to the `en` locale strings.
Each node translation file is an object that allows for two keys, `header` and `nodeView`, which are the _sections_ of each node translation:
```json
{
"header": { ... },
"nodeView": { ... },
}
```
The `header` section points to an object that may contain only two keys, `displayName` and `description`, matching the node's `description.displayName` and `description.description`.
```ts
export class Github implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub', // key to use in translation
description: 'Consume GitHub API', // key to use in translation
name: 'github',
icon: 'file:github.svg',
group: ['input'],
version: 1,
```
```json
{
"header": {
"displayName": "🇩🇪 GitHub",
"description": "🇩🇪 Consume GitHub API",
},
}
```
Header text is used wherever the node's display name and description are needed:
<p align="center">
<img src="img/header1.png" width="400">
<img src="img/header2.png" width="200">
<img src="img/header3.png" width="400">
</p>
<p align="center">
<img src="img/header4.png" width="400">
<img src="img/header5.png" width="500">
</p>
In turn, the `nodeView` section points to an object containing translation keys that match the node's operational parameters, found in the `*.node.ts` and also found in `*Description.ts` files in the same dir.
```ts
export class Github implements INodeType {
@ -348,23 +279,17 @@ export class Github implements INodeType {
```json
{
"github": {
"header": {},
"credentialsModal": {},
"nodeView": {
"resource": {}, // key from node parameter name
},
"nodeView": {
"resource": {...},
},
}
```
> **Note**: Other than in the `*.node.ts` file, operational parameters may also be found in `*Description.ts` files in the same dir, e.g. `UserDescription.ts`.
A node parameter allows for different translation keys depending on parameter type.
#### `string`, `number` and `boolean` parameters
Allowed keys: `displayName`, `description`, and `placeholder`.
Allowed keys: `displayName`, `description`, `placeholder`
```ts
{
@ -379,31 +304,27 @@ Allowed keys: `displayName`, `description`, and `placeholder`.
```json
{
"github": {
"header": {},
"credentialsModal": {},
"nodeView": {
"owner": { // key from node parameter name
"displayName": "🇩🇪 Repository Owner",
"placeholder": "🇩🇪 n8n-io",
"description": "🇩🇪 Owner of the repository.",
},
"nodeView": {
"owner": {
"displayName": "🇩🇪 Repository Owner",
"placeholder": "🇩🇪 n8n-io",
"description": "🇩🇪 Owner of the repository",
},
},
}
```
<p align="center">
<img src="img/node1.png" width="400">
<img src="img/node1.png" width="400">
</p>
#### `options` parameter
Allowed keys: `displayName`, `description`, and `placeholder`.
Allowed keys: `displayName`, `description`, `placeholder`
Allowed subkeys: `options.{optionName}.displayName` and `options.{optionName}.description`.
```ts
```js
{
displayName: 'Resource',
name: 'resource',
@ -419,26 +340,22 @@ Allowed subkeys: `options.{optionName}.displayName` and `options.{optionName}.de
},
],
default: 'issue',
description: 'The resource to operate on.',
description: 'Resource to operate on',
},
```
```json
{
"github": {
"header": {},
"credentialsModal": {},
"nodeView": {
"resource": {
"displayName": "🇩🇪 Resource",
"description": "🇩🇪 The resource to operate on.",
"options": {
"file": { // key from node parameter options name
"displayName": "🇩🇪 File",
},
"issue": { // key from node parameter options name
"displayName": "🇩🇪 Issue",
},
"nodeView": {
"resource": {
"displayName": "🇩🇪 Resource",
"description": "🇩🇪 Resource to operate on",
"options": {
"file": {
"displayName": "🇩🇪 File",
},
"issue": {
"displayName": "🇩🇪 Issue",
},
},
},
@ -447,14 +364,16 @@ Allowed subkeys: `options.{optionName}.displayName` and `options.{optionName}.de
```
<p align="center">
<img src="img/node2.png" width="400">
<img src="img/node2.png" width="400">
</p>
#### `collection` and `fixedCollection` parameters
Allowed keys: `displayName`, `description`, `placeholder`, and `multipleValueButtonText`.
Allowed keys: `displayName`, `description`, `placeholder`, `multipleValueButtonText`
```ts
Example of `collection` parameter:
```js
{
displayName: 'Labels',
name: 'labels', // key to use in translation
@ -480,68 +399,112 @@ Allowed keys: `displayName`, `description`, `placeholder`, and `multipleValueBut
name: 'label', // key to use in translation
type: 'string',
default: '',
description: 'Label to add to issue.',
description: 'Label to add to issue',
},
],
},
```
To reduce nesting and to share translations, a parameter inside a collection's or fixed collection's `options` parameter sits at the same level of nesting as the containing collection in the `nodeView` section:
```json
{
"nodeView": {
"labels": {
"displayName": "🇩🇪 Labels",
"multipleValueButtonText": "🇩🇪 Add Label",
"options": {
"label": {
"displayName": "🇩🇪 Label",
"description": "🇩🇪 Label to add to issue",
"placeholder": "🇩🇪 Some placeholder"
}
}
}
}
}
```
Example of `fixedCollection` parameter:
```js
{
displayName: 'Additional Parameters',
name: 'additionalParameters',
placeholder: 'Add Parameter',
description: 'Additional fields to add.',
type: 'fixedCollection',
default: {},
displayOptions: {
show: {
operation: [
'create',
'delete',
'edit',
],
resource: [
'file',
],
},
},
options: [
{
name: 'author',
displayName: 'Author',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description: 'Name of the author of the commit',
placeholder: 'John',
},
{
displayName: 'Email',
name: 'email',
type: 'string',
default: '',
description: 'Email of the author of the commit',
placeholder: 'john@email.com',
},
],
},
],
}
```
```json
{
"github": {
"header": {},
"credentialsModal": {},
"nodeView": {
// collection
"labels": {
"displayName": "🇩🇪 Labels",
"multipleValueButtonText": "🇩🇪 Add Label",
},
// collection item - same level of nesting
"label": {
"displayName": "🇩🇪 Label",
"description": "🇩🇪 Label to add to issue.",
},
// fixed collection
"additionalParameters": {
"displayName": "🇩🇪 Additional Fields",
"options": {
"author": {
"displayName": "🇩🇪 Author",
},
"nodeView": {
"additionalParameters": {
"displayName": "🇩🇪 Additional Parameters",
"placeholder": "🇩🇪 Add Field",
"options": {
"author": {
"displayName": "🇩🇪 Author",
"values": {
"name": {
"displayName": "🇩🇪 Name",
"description": "🇩🇪 Name of the author of the commit",
"placeholder": "🇩🇪 Jan"
},
"email": {
"displayName": "🇩🇪 Email",
"description": "🇩🇪 Email of the author of the commit",
"placeholder": "🇩🇪 jan@n8n.io"
}
}
},
},
// fixed collection item - same level of nesting
"author": {
"displayName": "🇩🇪 Author",
},
},
},
}
}
}
}
```
<p align="center">
<img src="img/node4.png" width="400">
<img src="img/node4.png" width="400">
</p>
> **Note**: In case of deep nesting, i.e. a child of a child of a `collection` and `fixedCollection` parameter, the deeply nested child in principle should be translatable at the same level of nesting as the `collection` and `fixedCollection` parameter, but this has not been fully tested for this first release.
#### Reusable dynamic text
The base text file may contain the special key `reusableDynamicText`, allowing for a node parameter to be translated once and reused in all other node parameter translations.
Currently only the keys `oauth.clientId` and `oauth.clientSecret` are supported as a PoC - these two translations will be reused in all node credential parameters.
```json
{
"reusableDynamicText": {
"oauth2": {
"clientId": "🇩🇪 Client ID",
"clientSecret": "🇩🇪 Client Secret",
```
> For information on **reusable dynamic text**, refer to the [Addendum](./ADDENDUM.md).
# Building translations
@ -568,7 +531,7 @@ Changing the base text file will trigger a rebuild of the client at `http://loca
## Dynamic text
When translating a dynamic text file at `/packages/nodes-base/nodes/{node}/translations/{localeIdentifier}.json`,
When translating a dynamic text file at `/packages/nodes-base/nodes/{node}/translations/{localeIdentifier}/{node}.json`,
1. Open a terminal:
@ -593,4 +556,4 @@ After changing the dynamic text file:
If a `headerText` section was changed, re-run `npm run build:translations` in `/nodes-base`.
> **Note**: To translate base and dynamic text simultaneously, run three terminals following the steps from both sections (first terminal running only once) and browse `http://localhost:8080`.
> **Note**: To translate base and dynamic text simultaneously, run three terminals following the steps from both sections (first terminal running only once) and browse `http://localhost:8080`.

View file

@ -4,14 +4,17 @@ import VueI18n from 'vue-i18n';
import { Store } from "vuex";
import Vue from 'vue';
import { INodeTranslationHeaders, IRootState } from '@/Interface';
import {
deriveMiddleKey,
isNestedInCollectionLike,
normalize,
insertOptionsAndValues,
} from "./utils";
const englishBaseText = require('./locales/en');
Vue.use(VueI18n);
const REUSABLE_DYNAMIC_TEXT_KEY = 'reusableDynamicText';
const CREDENTIALS_MODAL_KEY = 'credentialsModal';
const NODE_VIEW_KEY = 'nodeView';
export function I18nPlugin(vue: typeof _Vue, store: Store<IRootState>): void {
const i18n = new I18nClass(store);
@ -43,10 +46,6 @@ export class I18nClass {
return this.i18n.te(key);
}
number(value: number, options: VueI18n.FormattedNumberPartType) {
return this.i18n.n(value, options);
}
shortNodeType(longNodeType: string) {
return longNodeType.replace('n8n-nodes-base.', '');
}
@ -56,16 +55,17 @@ export class I18nClass {
// ----------------------------------
/**
* Render a string of base text, i.e. a string with a fixed path to the localized value in the base text object. Optionally allows for [interpolation](https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting) when the localized value contains a string between curly braces.
* Render a string of base text, i.e. a string with a fixed path to the localized value. Optionally allows for [interpolation](https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting) when the localized value contains a string between curly braces.
*/
baseText(
key: string, options?: { interpolate: { [key: string]: string } },
key: string,
options?: { interpolate: { [key: string]: string } },
): string {
return this.i18n.t(key, options && options.interpolate).toString();
}
/**
* Render a string of dynamic text, i.e. a string with a constructed path to the localized value in the node text object, in the credentials modal, in the node view, or in the headers. Unlike in `baseText`, the fallback has to be set manually for dynamic text.
* Render a string of dynamic text, i.e. a string with a constructed path to the localized value.
*/
private dynamicRender(
{ key, fallback }: { key: string; fallback: string; },
@ -74,30 +74,32 @@ export class I18nClass {
}
/**
* Render a string of dynamic header text, used in the nodes panel and in the node view.
* Render a string of header text (a node's name and description),
* used variously in the nodes panel, under the node icon, etc.
*/
headerText(arg: { key: string; fallback: string; }) {
return this.dynamicRender(arg);
}
/**
* Namespace for methods to render text in the credentials details modal.
*/
credText () {
const { credentialTextRenderKeys: keys } = this.$store.getters;
const nodeType = keys ? keys.nodeType : '';
const credentialType = keys ? keys.credentialType : '';
const credentialPrefix = `${nodeType}.${CREDENTIALS_MODAL_KEY}.${credentialType}`;
const credentialType = this.$store.getters.activeCredentialType;
const credentialPrefix = `n8n-nodes-base.credentials.${credentialType}`;
const context = this;
return {
/**
* Display name for a top-level parameter in the credentials modal.
* Display name for a top-level param.
*/
topParameterDisplayName(
inputLabelDisplayName(
{ name: parameterName, displayName }: { name: string; displayName: string; },
) {
if (['clientId', 'clientSecret'].includes(parameterName)) {
return context.dynamicRender({
key: `${REUSABLE_DYNAMIC_TEXT_KEY}.oauth2.${parameterName}`,
key: `reusableDynamicText.oauth2.${parameterName}`,
fallback: displayName,
});
}
@ -109,9 +111,9 @@ export class I18nClass {
},
/**
* Description for a top-level parameter in the credentials modal.
* Description (tooltip text) for an input label param.
*/
topParameterDescription(
inputLabelDescription(
{ name: parameterName, description }: { name: string; description: string; },
) {
return context.dynamicRender({
@ -121,7 +123,7 @@ export class I18nClass {
},
/**
* Display name for an option inside an `options` or `multiOptions` parameter in the credentials modal.
* Display name for an option inside an `options` or `multiOptions` param.
*/
optionsOptionDisplayName(
{ name: parameterName }: { name: string; },
@ -134,7 +136,7 @@ export class I18nClass {
},
/**
* Description for an option inside an `options` or `multiOptions` parameter in the credentials modal.
* Description for an option inside an `options` or `multiOptions` param.
*/
optionsOptionDescription(
{ name: parameterName }: { name: string; },
@ -147,7 +149,7 @@ export class I18nClass {
},
/**
* Placeholder for a `string` or `collection` or `fixedCollection` parameter in the credentials modal.
* Placeholder for a `string` or `collection` or `fixedCollection` param.
* - For a `string` parameter, the placeholder is unselectable greyed-out sample text.
* - For a `collection` or `fixedCollection` parameter, the placeholder is the button text.
*/
@ -162,99 +164,158 @@ export class I18nClass {
};
}
/**
* Namespace for methods to render text in the node details view,
* except for `eventTriggerDescription`.
*/
nodeText () {
const type = this.$store.getters.activeNode.type;
const nodePrefix = `${type}.${NODE_VIEW_KEY}`;
const activeNode = this.$store.getters.activeNode;
const nodeType = activeNode ? this.shortNodeType(activeNode.type) : ''; // unused in eventTriggerDescription
const initialKey = `n8n-nodes-base.nodes.${nodeType}.nodeView`;
const context = this;
return {
/**
* Display name for a top-level parameter in the node view.
* Display name for an input label, whether top-level or nested.
*/
topParameterDisplayName(
{ name: parameterName, displayName }: { name: string; displayName: string; },
inputLabelDisplayName(
parameter: { name: string; displayName: string; type: string },
path: string,
) {
const middleKey = deriveMiddleKey(path, parameter);
return context.dynamicRender({
key: `${nodePrefix}.${parameterName}.displayName`,
fallback: displayName,
key: `${initialKey}.${middleKey}.displayName`,
fallback: parameter.displayName,
});
},
/**
* Description for a top-level parameter in the node view in the node view.
* Description (tooltip text) for an input label, whether top-level or nested.
*/
topParameterDescription(
{ name: parameterName, description }: { name: string; description: string; },
inputLabelDescription(
parameter: { name: string; description: string; type: string },
path: string,
) {
const middleKey = deriveMiddleKey(path, parameter);
return context.dynamicRender({
key: `${nodePrefix}.${parameterName}.description`,
fallback: description,
key: `${initialKey}.${middleKey}.description`,
fallback: parameter.description,
});
},
/**
* Display name for an option inside a `collection` or `fixedCollection` parameter in the node view.
* Placeholder for an input label or `collection` or `fixedCollection` param,
* whether top-level or nested.
* - For an input label, the placeholder is unselectable greyed-out sample text.
* - For a `collection` or `fixedCollection`, the placeholder is the button text.
*/
collectionOptionDisplayName(
{ name: parameterName }: { name: string; },
{ name: optionName, displayName }: { name: string; displayName: string; },
placeholder(
parameter: { name: string; placeholder: string; type: string },
path: string,
) {
let middleKey = parameter.name;
if (isNestedInCollectionLike(path)) {
const pathSegments = normalize(path).split('.');
middleKey = insertOptionsAndValues(pathSegments).join('.');
}
return context.dynamicRender({
key: `${nodePrefix}.${parameterName}.options.${optionName}.displayName`,
fallback: displayName,
key: `${initialKey}.${middleKey}.placeholder`,
fallback: parameter.placeholder,
});
},
/**
* Display name for an option inside an `options` or `multiOptions` parameter in the node view.
* Display name for an option inside an `options` or `multiOptions` param,
* whether top-level or nested.
*/
optionsOptionDisplayName(
{ name: parameterName }: { name: string; },
parameter: { name: string; },
{ value: optionName, name: displayName }: { value: string; name: string; },
path: string,
) {
let middleKey = parameter.name;
if (isNestedInCollectionLike(path)) {
const pathSegments = normalize(path).split('.');
middleKey = insertOptionsAndValues(pathSegments).join('.');
}
return context.dynamicRender({
key: `${nodePrefix}.${parameterName}.options.${optionName}.displayName`,
key: `${initialKey}.${middleKey}.options.${optionName}.displayName`,
fallback: displayName,
});
},
/**
* Description for an option inside an `options` or `multiOptions` parameter in the node view.
* Description for an option inside an `options` or `multiOptions` param,
* whether top-level or nested.
*/
optionsOptionDescription(
{ name: parameterName }: { name: string; },
parameter: { name: string; },
{ value: optionName, description }: { value: string; description: string; },
path: string,
) {
let middleKey = parameter.name;
if (isNestedInCollectionLike(path)) {
const pathSegments = normalize(path).split('.');
middleKey = insertOptionsAndValues(pathSegments).join('.');
}
return context.dynamicRender({
key: `${nodePrefix}.${parameterName}.options.${optionName}.description`,
key: `${initialKey}.${middleKey}.options.${optionName}.description`,
fallback: description,
});
},
/**
* Text for a button to add another option inside a `collection` or `fixedCollection` parameter having`multipleValues: true` in the node view.
* Display name for an option in the dropdown menu of a `collection` or
* fixedCollection` param. No nesting support since `collection` cannot
* be nested in a `collection` or in a `fixedCollection`.
*/
collectionOptionDisplayName(
parameter: { name: string; },
{ name: optionName, displayName }: { name: string; displayName: string; },
path: string,
) {
let middleKey = parameter.name;
if (isNestedInCollectionLike(path)) {
const pathSegments = normalize(path).split('.');
middleKey = insertOptionsAndValues(pathSegments).join('.');
}
return context.dynamicRender({
key: `${initialKey}.${middleKey}.options.${optionName}.displayName`,
fallback: displayName,
});
},
/**
* Text for a button to add another option inside a `collection` or
* `fixedCollection` param having `multipleValues: true`.
*/
multipleValueButtonText(
{ name: parameterName, typeOptions: { multipleValueButtonText } }:
{ name: string; typeOptions: { multipleValueButtonText: string; } },
) {
return context.dynamicRender({
key: `${nodePrefix}.${parameterName}.multipleValueButtonText`,
key: `${initialKey}.${parameterName}.multipleValueButtonText`,
fallback: multipleValueButtonText,
});
},
/**
* Placeholder for a `string` or `collection` or `fixedCollection` parameter in the node view.
* - For a `string` parameter, the placeholder is unselectable greyed-out sample text.
* - For a `collection` or `fixedCollection` parameter, the placeholder is the button text.
*/
placeholder(
{ name: parameterName, placeholder }: { name: string; placeholder: string; },
eventTriggerDescription(
nodeType: string,
eventTriggerDescription: string,
) {
return context.dynamicRender({
key: `${nodePrefix}.${parameterName}.placeholder`,
fallback: placeholder,
key: `n8n-nodes-base.nodes.${nodeType}.nodeView.eventTriggerDescription`,
fallback: eventTriggerDescription,
});
},
};
@ -302,14 +363,25 @@ export async function loadLanguage(language?: string) {
setLanguage(language);
}
/**
* Add a node translation to the i18n instance's `messages` object.
*/
export function addNodeTranslation(
nodeTranslation: { [key: string]: object },
nodeTranslation: { [nodeType: string]: object },
language: string,
) {
const oldNodesBase = i18nInstance.messages[language]['n8n-nodes-base'] || {};
const updatedNodes = {
// @ts-ignore
...oldNodesBase.nodes,
...nodeTranslation,
};
const newNodesBase = {
'n8n-nodes-base': Object.assign(
i18nInstance.messages[language]['n8n-nodes-base'] || {},
nodeTranslation,
oldNodesBase,
{ nodes: updatedNodes },
),
};
@ -319,6 +391,37 @@ export function addNodeTranslation(
);
}
/**
* Add a credential translation to the i18n instance's `messages` object.
*/
export function addCredentialTranslation(
nodeCredentialTranslation: { [credentialType: string]: object },
language: string,
) {
const oldNodesBase = i18nInstance.messages[language]['n8n-nodes-base'] || {};
const updatedCredentials = {
// @ts-ignore
...oldNodesBase.credentials,
...nodeCredentialTranslation,
};
const newNodesBase = {
'n8n-nodes-base': Object.assign(
oldNodesBase,
{ credentials: updatedCredentials },
),
};
i18nInstance.setLocaleMessage(
language,
Object.assign(i18nInstance.messages[language], newNodesBase),
);
}
/**
* Add a node's header strings to the i18n instance's `messages` object.
*/
export function addHeaders(
headers: INodeTranslationHeaders,
language: string,
@ -327,4 +430,4 @@ export function addHeaders(
language,
Object.assign(i18nInstance.messages[language], { headers }),
);
}
}

View file

@ -386,7 +386,8 @@
"issues": "Issues",
"nodeIsExecuting": "Node is executing",
"nodeIsWaitingTill": "Node is waiting till {date} {time}",
"theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall": "The node is waiting indefinitely for an incoming webhook call."
"theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall": "The node is waiting indefinitely for an incoming webhook call.",
"waitingForYouToCreateAnEventIn": "Waiting for you to create an event in {nodeType}"
},
"nodeCreator": {
"categoryNames": {

View file

@ -0,0 +1,76 @@
/**
* Derive the middle key, i.e. the segment of the render key located between
* the initial key (path to parameters root) and the property to render.
*
* Used by `nodeText()` to handle nested params.
*
* Location: `n8n-nodes-base.nodes.github.nodeView.<middleKey>.placeholder`
*/
export function deriveMiddleKey(
path: string,
parameter: { name: string; type: string; },
) {
let middleKey = parameter.name;
if (
isTopLevelCollection(path, parameter) ||
isNestedInCollectionLike(path)
) {
const pathSegments = normalize(path).split('.');
middleKey = insertOptionsAndValues(pathSegments).join('.');
}
if (
isNestedCollection(path, parameter) ||
isFixedCollection(path, parameter)
) {
const pathSegments = [...normalize(path).split('.'), parameter.name];
middleKey = insertOptionsAndValues(pathSegments).join('.');
}
return middleKey;
}
/**
* Check if a param path is for a param nested inside a `collection` or
* `fixedCollection` param.
*/
export const isNestedInCollectionLike = (path: string) => path.split('.').length >= 3;
const isTopLevelCollection = (path: string, parameter: { type: string }) =>
path.split('.').length === 2 && parameter.type === 'collection';
const isNestedCollection = (path: string, parameter: { type: string }) =>
path.split('.').length > 2 && parameter.type === 'collection';
/**
* Check if the param is a normal `fixedCollection`, i.e. a FC other than the wrapper
* that sits at the root of a node's top-level param and contains all of them.
*/
const isFixedCollection = (path: string, parameter: { type: string }) =>
parameter.type === 'fixedCollection' && path !== 'parameters';
/**
* Remove all indices and the `parameters.` prefix from a parameter path.
*
* Example: `parameters.a[0].b` `a.b`
*/
export const normalize = (path: string) => path.replace(/\[.*?\]/g, '').replace('parameters.', '');
/**
* Insert `'options'` and `'values'` on an alternating basis in a string array of
* indefinite length. Helper to create a valid render key for a collection-like param.
*
* Example: `['a', 'b', 'c']` `['a', 'options', 'b', 'values', 'c']`
*/
export const insertOptionsAndValues = (pathSegments: string[]) => {
return pathSegments.reduce<string[]>((acc, cur, i) => {
acc.push(cur);
if (i === pathSegments.length - 1) return acc;
acc.push(i % 2 === 0 ? 'options' : 'values');
return acc;
}, []);
};

View file

@ -46,9 +46,9 @@ const state: IRootState = {
activeWorkflows: [],
activeActions: [],
activeNode: null,
activeCredentialType: null,
// @ts-ignore
baseUrl: process.env.VUE_APP_URL_BASE_API ? process.env.VUE_APP_URL_BASE_API : (window.BASE_PATH === '/%BASE_PATH%/' ? '/' : window.BASE_PATH),
credentialTextRenderKeys: null,
defaultLocale: 'en',
endpointWebhook: 'webhook',
endpointWebhookTest: 'webhook-test',
@ -560,8 +560,8 @@ export const store = new Vuex.Store({
setActiveNode (state, nodeName: string) {
state.activeNode = nodeName;
},
setCredentialTextRenderKeys (state, renderKeys: { nodeType: string; credentialType: string; }) {
state.credentialTextRenderKeys = renderKeys;
setActiveCredentialType (state, activeCredentialType: string) {
state.activeCredentialType = activeCredentialType;
},
setLastSelectedNode (state, nodeName: string) {
@ -647,6 +647,9 @@ export const store = new Vuex.Store({
},
},
getters: {
activeCredentialType: (state): string | null => {
return state.activeCredentialType;
},
isActionActive: (state) => (action: string): boolean => {
return state.activeActions.includes(action);
@ -668,10 +671,6 @@ export const store = new Vuex.Store({
return state.activeExecutions;
},
credentialTextRenderKeys: (state): object | null => {
return state.credentialTextRenderKeys;
},
getBaseUrl: (state): string => {
return state.baseUrl;
},

View file

@ -2673,7 +2673,12 @@ export default mixins(
nodesInfo.forEach(nodeInfo => {
if (nodeInfo.translation) {
addNodeTranslation(nodeInfo.translation, this.$store.getters.defaultLocale);
const nodeType = this.$locale.shortNodeType(nodeInfo.name);
addNodeTranslation(
{ [nodeType]: nodeInfo.translation },
this.$store.getters.defaultLocale,
);
}
});

View file

@ -16,7 +16,7 @@ function copyIcons() {
task('build:translations', writeHeaders);
/**
* Write all node translation headers at `/dist/nodes/headers.js`.
* Write node translation headers to single file at `/dist/nodes/headers.js`.
*/
function writeHeaders(done) {
const { N8N_DEFAULT_LOCALE: locale } = process.env;
@ -26,65 +26,48 @@ function writeHeaders(done) {
if (!locale || locale === 'en') {
log('No translation required - Skipping translations build...');
return done();
};
}
const paths = getTranslationPaths();
const headers = getHeaders(paths);
const nodeTranslationPaths = getNodeTranslationPaths();
const headers = getHeaders(nodeTranslationPaths);
const headersDistPath = path.join(__dirname, 'dist', 'nodes', 'headers.js');
const headersDestinationPath = path.join(__dirname, 'dist', 'nodes', 'headers.js');
writeDistFile(headers, headersDistPath);
writeDestinationFile(headersDestinationPath, headers);
log('Headers translation file written to:');
log(headersDestinationPath, { bulletpoint: true });
log('Headers file written to:');
log(headersDistPath, { bulletpoint: true });
done();
}
function getTranslationPaths() {
const destinationPaths = require('./package.json').n8n.nodes;
function getNodeTranslationPaths() {
const nodeDistPaths = require('./package.json').n8n.nodes;
const { N8N_DEFAULT_LOCALE: locale } = process.env;
const seen = {};
return destinationPaths.reduce((acc, cur) => {
const sourcePath = path.join(
return nodeDistPaths.reduce((acc, cur) => {
const nodeTranslationPath = path.join(
__dirname,
cur.split('/').slice(1, -1).join('/'),
'translations',
`${locale}.json`,
locale,
toTranslationFile(cur),
);
if (existsSync(sourcePath) && !seen[sourcePath]) {
seen[sourcePath] = true;
const destinationPath = path.join(
__dirname,
cur.split('/').slice(0, -1).join('/'),
'translations',
`${locale}.json`,
);
acc.push({
source: sourcePath,
destination: destinationPath,
});
if (existsSync(nodeTranslationPath)) {
acc.push(nodeTranslationPath);
};
return acc;
}, []);
}
function getHeaders(paths) {
return paths.reduce((acc, cur) => {
const translation = require(cur.source);
const nodeTypes = Object.keys(translation);
function getHeaders(nodeTranslationPaths) {
return nodeTranslationPaths.reduce((acc, cur) => {
const { header } = require(cur);
const nodeType = cur.split('/').pop().replace('.json', '');
for (const nodeType of nodeTypes) {
const { header } = translation[nodeType];
if (isValidHeader(header, ALLOWED_HEADER_KEYS)) {
acc[nodeType] = header;
}
if (isValidHeader(header, ALLOWED_HEADER_KEYS)) {
acc[nodeType] = header;
}
return acc;
@ -96,6 +79,11 @@ function getHeaders(paths) {
// helpers
// ----------------------------------
function toTranslationFile(distPath) {
const raw = distPath.split('/').pop().replace('.node', '') + 'on';
return raw.charAt(0).toLowerCase() + raw.slice(1);
}
function isValidHeader(header, allowedHeaderKeys) {
if (!header) return false;
@ -105,9 +93,9 @@ function isValidHeader(header, allowedHeaderKeys) {
headerKeys.every(key => allowedHeaderKeys.includes(key));
}
function writeDestinationFile(destinationPath, data) {
function writeDistFile(data, distPath) {
writeFile(
destinationPath,
distPath,
`module.exports = ${JSON.stringify(data, null, 2)}`,
);
}

View file

@ -25,6 +25,7 @@
"src/**/*",
"nodes/**/*",
"nodes/**/*.json",
"credentials/translations/**/*.json",
"test/**/*"
],
"exclude": [