From 941545ab217a865963131e1692a31e1db8c337cd Mon Sep 17 00:00:00 2001 From: Vadim <1074746@gmail.com> Date: Mon, 15 Jan 2024 08:41:59 +0200 Subject: [PATCH] Add new files for label management --- .editorconfig | 18 + .eslintignore | 1 + .eslintrc.json | 24 + .gitattributes | 5 + .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug_report.md | 38 ++ .github/ISSUE_TEMPLATE/custom.md | 10 + .github/ISSUE_TEMPLATE/feature_request.md | 20 + .github/workflows/issue-check-inactive.yml | 22 + .github/workflows/issue-close-require.yml | 31 + .github/workflows/release-package.yml | 25 + .gitignore | 135 +--- .mocharc.json | 21 + .prettierrc.json | 1 + README.md | 678 ++++++++++++++++++++- bin/dev.cmd | 3 + bin/dev.js | 9 + bin/run.cmd | 3 + bin/run.js | 9 + package.json | 113 ++++ scripts/bing.generate-config.mjs | 113 ++++ scripts/terraprint.generate-config.mjs | 45 ++ src/commands/cache.ts | 62 ++ src/commands/init.ts | 51 ++ src/commands/label/add.ts | 120 ++++ src/commands/label/delete.ts | 131 ++++ src/commands/label/index.ts | 17 + src/commands/label/replace.ts | 76 +++ src/commands/label/sync.ts | 257 ++++++++ src/commands/translate/bing.ts | 73 +++ src/commands/translate/terra.ts | 86 +++ src/index.ts | 1 + src/shared/config.ts | 85 +++ src/shared/constants.ts | 4 + src/shared/engines/base-engine-bing.ts | 18 + src/shared/engines/bing.engine.ts | 322 ++++++++++ src/shared/engines/terra.engine.ts | 99 +++ src/shared/engines/translate.engine.ts | 67 ++ src/shared/entities/bing.config.ts | 79 +++ src/shared/entities/report.ts | 11 + src/shared/entities/terra.config.ts | 33 + src/shared/entities/translate.ts | 14 + src/shared/entities/translation.engine.ts | 23 + src/shared/helper.ts | 43 ++ src/shared/label-base.command.ts | 196 ++++++ src/shared/lang.bing.ts | 145 +++++ src/shared/lang.terra.ts | 41 ++ src/shared/util.ts | 191 ++++++ test/commands/cache.test.ts | 10 + test/commands/init.test.ts | 51 ++ test/commands/label/add.test.ts | 10 + test/commands/label/delete.test.ts | 10 + test/commands/label/replace.test.ts | 10 + test/commands/label/sync.test.ts | 11 + test/commands/translate/bing.test.ts | 12 + test/init.ts | 5 + test/tsconfig.json | 14 + tsconfig.json | 23 + types/index.d.ts | 0 59 files changed, 3609 insertions(+), 117 deletions(-) create mode 100644 .editorconfig create mode 100644 .eslintignore create mode 100644 .eslintrc.json create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/custom.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/workflows/issue-check-inactive.yml create mode 100644 .github/workflows/issue-close-require.yml create mode 100644 .github/workflows/release-package.yml create mode 100644 .mocharc.json create mode 100644 .prettierrc.json create mode 100644 bin/dev.cmd create mode 100644 bin/dev.js create mode 100644 bin/run.cmd create mode 100644 bin/run.js create mode 100644 package.json create mode 100644 scripts/bing.generate-config.mjs create mode 100644 scripts/terraprint.generate-config.mjs create mode 100644 src/commands/cache.ts create mode 100644 src/commands/init.ts create mode 100644 src/commands/label/add.ts create mode 100644 src/commands/label/delete.ts create mode 100644 src/commands/label/index.ts create mode 100644 src/commands/label/replace.ts create mode 100644 src/commands/label/sync.ts create mode 100644 src/commands/translate/bing.ts create mode 100644 src/commands/translate/terra.ts create mode 100644 src/index.ts create mode 100644 src/shared/config.ts create mode 100644 src/shared/constants.ts create mode 100644 src/shared/engines/base-engine-bing.ts create mode 100644 src/shared/engines/bing.engine.ts create mode 100644 src/shared/engines/terra.engine.ts create mode 100644 src/shared/engines/translate.engine.ts create mode 100644 src/shared/entities/bing.config.ts create mode 100644 src/shared/entities/report.ts create mode 100644 src/shared/entities/terra.config.ts create mode 100644 src/shared/entities/translate.ts create mode 100644 src/shared/entities/translation.engine.ts create mode 100644 src/shared/helper.ts create mode 100644 src/shared/label-base.command.ts create mode 100644 src/shared/lang.bing.ts create mode 100644 src/shared/lang.terra.ts create mode 100644 src/shared/util.ts create mode 100644 test/commands/cache.test.ts create mode 100644 test/commands/init.test.ts create mode 100644 test/commands/label/add.test.ts create mode 100644 test/commands/label/delete.test.ts create mode 100644 test/commands/label/replace.test.ts create mode 100644 test/commands/label/sync.test.ts create mode 100644 test/commands/translate/bing.test.ts create mode 100644 test/init.ts create mode 100644 test/tsconfig.json create mode 100644 tsconfig.json create mode 100644 types/index.d.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..47510e9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +max_line_length = 120 + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..9b1c8b1 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +/dist diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..f011396 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,24 @@ +{ + "extends": ["oclif", "oclif-typescript", "prettier"], + "rules": { + "unicorn/no-abusive-eslint-disable": "off", + "unicorn/no-array-reduce": "off", + "arrow-body-style": "off", + "object-shorthand": "off", + "no-else-return": "off", + "unicorn/no-array-for-each": "off", + "no-await-in-loop": "off", + "unicorn/switch-case-braces": "off", + "valid-jsdoc": "off", + "guard-for-in": "off", + "unicorn/catch-error-name": [ + "error", + { + "ignore": [ + "^error\\d*$", + "^reason\\d*$" + ] + } + ] + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..322a710 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# All files are checked into the repo with LF +* text=auto eol=lf + +# These files are checked out using CRLF locally +*.bat eol=crlf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..61900eb --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @4746 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8dac222 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: 'bug' +assignees: '4746' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 0000000..0cc3b49 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,10 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: 'сustom' +assignees: '4746' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..15dd69b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: 'feature' +assignees: '4746' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/issue-check-inactive.yml b/.github/workflows/issue-check-inactive.yml new file mode 100644 index 0000000..9a79731 --- /dev/null +++ b/.github/workflows/issue-check-inactive.yml @@ -0,0 +1,22 @@ +name: Issue Check Inactive + +on: + schedule: + - cron: "0 0 */15 * *" + +permissions: + contents: read + +jobs: + issue-check-inactive: + permissions: + issues: write # for actions-cool/issues-helper to update issues + pull-requests: write # for actions-cool/issues-helper to update PRs + runs-on: ubuntu-latest + steps: + - name: check-inactive + uses: actions-cool/issues-helper@v3 + with: + actions: 'check-inactive' + inactive-label: 'Inactive' + inactive-day: 30 diff --git a/.github/workflows/issue-close-require.yml b/.github/workflows/issue-close-require.yml new file mode 100644 index 0000000..a22ea0f --- /dev/null +++ b/.github/workflows/issue-close-require.yml @@ -0,0 +1,31 @@ +name: Issue Close Require + +on: + schedule: + - cron: "0 0 * * *" + +permissions: + contents: read + +jobs: + issue-close-require: + permissions: + issues: write # for actions-cool/issues-helper to update issues + pull-requests: write # for actions-cool/issues-helper to update PRs + runs-on: ubuntu-latest + steps: + - name: need reproduce + uses: actions-cool/issues-helper@v3 + with: + actions: 'close-issues' + labels: '🤔 Need Reproduce' + inactive-day: 3 + + - name: needs more info + uses: actions-cool/issues-helper@v3 + with: + actions: 'close-issues' + labels: 'needs-more-info' + inactive-day: 3 + body: | + Since the issue was labeled with `needs-more-info`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply. diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml new file mode 100644 index 0000000..93b3a08 --- /dev/null +++ b/.github/workflows/release-package.yml @@ -0,0 +1,25 @@ +name: "Publish NPM Package" + +on: + release: + types: [created] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + - name: node + uses: actions/setup-node@v4 + with: + node-version: 18 + registry-url: https://registry.npmjs.org + - name: Install dependencies + run: npm install + - name: Build package + run: npm run prepack + - name: publish + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN_V107}} diff --git a/.gitignore b/.gitignore index c6bba59..891fbbb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,52 +1,27 @@ -# Logs -logs +# System files +.DS_Store +Thumbs.db *.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* +.npmrc -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +# IDEs and editors +.idea +# Visual Studio Code +.vscode -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release +# Miscellaneous +/.nyc_output +/dist +/lib +/tmp +package-lock.json +yarn.lock # Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo +node_modules +oclif.manifest.json +*.conf.json +*.config.json # Optional npm cache directory .npm @@ -56,75 +31,3 @@ web_modules/ # Optional stylelint cache .stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* diff --git a/.mocharc.json b/.mocharc.json new file mode 100644 index 0000000..20311f8 --- /dev/null +++ b/.mocharc.json @@ -0,0 +1,21 @@ +{ + "require": [ + "test/init.ts", + "ts-node/register" + ], + "extension": [ + "ts" + ], + "watch-extensions": "ts", + "recursive": true, + "reporter": "spec", + "color": true, + "timeout": 20000, + "ui": "bdd", + "full-trace": true, + "exclude": "node_modules/**/*", + "node-option": [ + "loader=ts-node/esm", + "trace-warnings" + ] +} diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..6314335 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1 @@ +"@oclif/prettier-config" diff --git a/README.md b/README.md index b14059b..50fc930 100644 --- a/README.md +++ b/README.md @@ -1 +1,677 @@ -# transverto \ No newline at end of file +CLI transverto +================= + +Label management command. + +[![NPM version](http://img.shields.io/npm/v/@cli107/transverto.svg?style=flat-square)](http://npmjs.org/package/@cli107/transverto) +[![GitHub license](https://img.shields.io/github/license/4746/transverto)](https://github.com/4746/transverto/blob/main/LICENSE) + + + +* [Usage](#usage) +* [Commands](#commands) + +* [Usage](#usage) +* [Commands](#commands) + +* [Usage](#usage) +* [Commands](#commands) + +# Usage + +```sh-session +$ npm install -g @cli107/transverto +$ ctv COMMAND +running command... +$ ctv (--version|-v) +@cli107/transverto/1.0.0 win32-x64 node-v18.19.0 +$ ctv --help [COMMAND] +USAGE + $ ctv COMMAND +... +``` + +```sh-session +$ npm install -g @cli107/transverto +$ ctv COMMAND +running command... +$ ctv (--version|-v) +@cli107/transverto/1.0.0 win32-x64 node-v18.19.0 +$ ctv --help [COMMAND] +USAGE + $ ctv COMMAND +... +``` + +```sh-session +$ npm install -g transverto +$ ctv COMMAND +running command... +$ ctv (--version|-v) +transverto/0.0.1 win32-x64 node-v18.19.0 +$ ctv --help [COMMAND] +USAGE + $ ctv COMMAND +... +``` + +# Commands + +* [`ctv cache`](#ctv-cache) +* [`ctv help [COMMANDS]`](#ctv-help-commands) +* [`ctv init`](#ctv-init) +* [`ctv label [ADD] [DELETE] [GET] [REPLACE] [SYNC]`](#ctv-label-add-delete-get-replace-sync) +* [`ctv label:add [LABEL]`](#ctv-labeladd-label) +* [`ctv label:delete LABEL`](#ctv-labeldelete-label) +* [`ctv label:replace LABEL`](#ctv-labelreplace-label) +* [`ctv label:sync`](#ctv-labelsync) + +## `ctv cache` + +Cache management command. + +``` +USAGE + $ ctv cache [-c] + +FLAGS + -c, --clear + +DESCRIPTION + Cache management command. + Cache dir: C:\Users\10747\AppData\Local\ctv + +EXAMPLES + $ ctv cache + + $ ctv cache --help +``` + +_See code: [src/commands/cache.ts](https://github.com/4746/transverto/blob/v1.0.0/src/commands/cache.ts)_ + +## `ctv help [COMMANDS]` + +Display help for ctv. + +``` +USAGE + $ ctv help [COMMANDS] [-n] + +ARGUMENTS + COMMANDS Command to show help for. + +FLAGS + -n, --nested-commands Include all nested commands in the output. + +DESCRIPTION + Display help for ctv. +``` + +_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.0.12/src/commands/help.ts)_ + +## `ctv init` + +Create a default configuration file + +``` +USAGE + $ ctv init [-f] + +FLAGS + -f, --force overwrite an existing file + +DESCRIPTION + Create a default configuration file + +EXAMPLES + $ ctv init + + $ ctv init --help + + $ ctv init --force +``` + +_See code: [src/commands/init.ts](https://github.com/4746/transverto/blob/v1.0.0/src/commands/init.ts)_ + +## `ctv label [ADD] [DELETE] [GET] [REPLACE] [SYNC]` + +Represents a label management command. + +``` +USAGE + $ ctv label [ADD] [DELETE] [GET] [REPLACE] [SYNC] + +ARGUMENTS + ADD Adds a new label. + DELETE Deletes a label. + GET Retrieves the labels. + REPLACE Replaces a label with the given value. + SYNC A command to update labels synchronously. + +DESCRIPTION + Represents a label management command. +``` + +_See code: [src/commands/label/index.ts](https://github.com/4746/transverto/blob/v1.0.0/src/commands/label/index.ts)_ + +## `ctv label:add [LABEL]` + +Add a new label + +``` +USAGE + $ ctv label:add [LABEL] [-f ] [--noAutoTranslate] [--silent] [-t ] + +ARGUMENTS + LABEL A label key + +FLAGS + -f, --fromLangCode= The language code of source text. + -t, --translation= Translation + --noAutoTranslate + --silent + +DESCRIPTION + Add a new label + +EXAMPLES + $ ctv label:add "hello.world" -f="en" + + $ ctv label:add "hello.world" -f en -t "Hello World!" + + $ ctv label:add "hello.world" -fen -t "Hello World!" + + $ ctv label:add "hello.world" -t "Hello World!" +``` + +_See code: [src/commands/label/add.ts](https://github.com/4746/transverto/blob/v1.0.0/src/commands/label/add.ts)_ + +## `ctv label:delete LABEL` + +Delete the specified label. + +``` +USAGE + $ ctv label:delete LABEL [-r] + +ARGUMENTS + LABEL A label key + +FLAGS + -r, --noReport + +DESCRIPTION + Delete the specified label. + +EXAMPLES + $ ctv label:delete hello + + $ ctv label:delete hello.world +``` + +_See code: [src/commands/label/delete.ts](https://github.com/4746/transverto/blob/v1.0.0/src/commands/label/delete.ts)_ + +## `ctv label:replace LABEL` + +Replace the label + +``` +USAGE + $ ctv label:replace LABEL -t [-f ] + +ARGUMENTS + LABEL A label key + +FLAGS + -f, --langCode= The language code of source text. + -t, --translation= (required) The translation text. + +DESCRIPTION + Replace the label + +EXAMPLES + $ ctv label:replace --help + + $ ctv label:replace hello.world -t="Hello world!!!" + + $ ctv label:replace hello.world -t="Hello world!!!" -fen +``` + +_See code: [src/commands/label/replace.ts](https://github.com/4746/transverto/blob/v1.0.0/src/commands/label/replace.ts)_ + +## `ctv label:sync` + +Synchronizing tags in translation files... + +``` +USAGE + $ ctv label:sync [--autoTranslate] [-r] [-s] + +FLAGS + -r, --noReport + -s, --silent + --autoTranslate + +DESCRIPTION + Synchronizing tags in translation files... + +EXAMPLES + $ ctv label:sync + + $ ctv label:sync "hello.world" -f="en" +``` + +_See code: [src/commands/label/sync.ts](https://github.com/4746/transverto/blob/v1.0.0/src/commands/label/sync.ts)_ + +* [`ctv cache`](#ctv-cache) +* [`ctv help [COMMANDS]`](#ctv-help-commands) +* [`ctv init`](#ctv-init) +* [`ctv label [ADD] [DELETE] [GET] [REPLACE] [SYNC]`](#ctv-label-add-delete-get-replace-sync) +* [`ctv label:add [LABEL]`](#ctv-labeladd-label) +* [`ctv label:delete LABEL`](#ctv-labeldelete-label) +* [`ctv label:replace LABEL`](#ctv-labelreplace-label) +* [`ctv label:sync`](#ctv-labelsync) + +## `ctv cache` + +Cache management command. + +``` +USAGE + $ ctv cache [-c] + +FLAGS + -c, --clear + +DESCRIPTION + Cache management command. + Cache dir: C:\Users\10747\AppData\Local\ctv + +EXAMPLES + $ ctv cache + + $ ctv cache --help +``` + +_See code: [src/commands/cache.ts](https://github.com/4746/transverto/blob/v1.0.0/src/commands/cache.ts)_ + +## `ctv help [COMMANDS]` + +Display help for ctv. + +``` +USAGE + $ ctv help [COMMANDS] [-n] + +ARGUMENTS + COMMANDS Command to show help for. + +FLAGS + -n, --nested-commands Include all nested commands in the output. + +DESCRIPTION + Display help for ctv. +``` + +_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.0.12/src/commands/help.ts)_ + +## `ctv init` + +Create a default configuration file + +``` +USAGE + $ ctv init [-f] + +FLAGS + -f, --force overwrite an existing file + +DESCRIPTION + Create a default configuration file + +EXAMPLES + $ ctv init + + $ ctv init --help + + $ ctv init --force +``` + +_See code: [src/commands/init.ts](https://github.com/4746/transverto/blob/v1.0.0/src/commands/init.ts)_ + +## `ctv label [ADD] [DELETE] [GET] [REPLACE] [SYNC]` + +Represents a label management command. + +``` +USAGE + $ ctv label [ADD] [DELETE] [GET] [REPLACE] [SYNC] + +ARGUMENTS + ADD Adds a new label. + DELETE Deletes a label. + GET Retrieves the labels. + REPLACE Replaces a label with the given value. + SYNC A command to update labels synchronously. + +DESCRIPTION + Represents a label management command. +``` + +_See code: [src/commands/label/index.ts](https://github.com/4746/transverto/blob/v1.0.0/src/commands/label/index.ts)_ + +## `ctv label:add [LABEL]` + +Add a new label + +``` +USAGE + $ ctv label:add [LABEL] [-f ] [--noAutoTranslate] [--silent] [-t ] + +ARGUMENTS + LABEL A label key + +FLAGS + -f, --fromLangCode= The language code of source text. + -t, --translation= Translation + --noAutoTranslate + --silent + +DESCRIPTION + Add a new label + +EXAMPLES + $ ctv label:add "hello.world" -f="en" + + $ ctv label:add "hello.world" -f en -t "Hello World!" + + $ ctv label:add "hello.world" -fen -t "Hello World!" + + $ ctv label:add "hello.world" -t "Hello World!" +``` + +_See code: [src/commands/label/add.ts](https://github.com/4746/transverto/blob/v1.0.0/src/commands/label/add.ts)_ + +## `ctv label:delete LABEL` + +Delete the specified label. + +``` +USAGE + $ ctv label:delete LABEL [-r] + +ARGUMENTS + LABEL A label key + +FLAGS + -r, --noReport + +DESCRIPTION + Delete the specified label. + +EXAMPLES + $ ctv label:delete hello + + $ ctv label:delete hello.world +``` + +_See code: [src/commands/label/delete.ts](https://github.com/4746/transverto/blob/v1.0.0/src/commands/label/delete.ts)_ + +## `ctv label:replace LABEL` + +Replace the label + +``` +USAGE + $ ctv label:replace LABEL -t [-f ] + +ARGUMENTS + LABEL A label key + +FLAGS + -f, --langCode= The language code of source text. + -t, --translation= (required) The translation text. + +DESCRIPTION + Replace the label + +EXAMPLES + $ ctv label:replace --help + + $ ctv label:replace hello.world -t="Hello world!!!" + + $ ctv label:replace hello.world -t="Hello world!!!" -fen +``` + +_See code: [src/commands/label/replace.ts](https://github.com/4746/transverto/blob/v1.0.0/src/commands/label/replace.ts)_ + +## `ctv label:sync` + +Synchronizing tags in translation files... + +``` +USAGE + $ ctv label:sync [--autoTranslate] [-r] [-s] + +FLAGS + -r, --noReport + -s, --silent + --autoTranslate + +DESCRIPTION + Synchronizing tags in translation files... + +EXAMPLES + $ ctv label:sync + + $ ctv label:sync "hello.world" -f="en" +``` + +_See code: [src/commands/label/sync.ts](https://github.com/4746/transverto/blob/v1.0.0/src/commands/label/sync.ts)_ + +* [`ctv cache`](#ctv-cache) +* [`ctv help [COMMANDS]`](#ctv-help-commands) +* [`ctv init`](#ctv-init) +* [`ctv label [ADD] [DELETE] [GET] [REPLACE] [SYNC]`](#ctv-label-add-delete-get-replace-sync) +* [`ctv label:add [LABEL]`](#ctv-labeladd-label) +* [`ctv label:delete LABEL`](#ctv-labeldelete-label) +* [`ctv label:replace LABEL`](#ctv-labelreplace-label) +* [`ctv label:sync`](#ctv-labelsync) + +## `ctv cache` + +Cache management command. + +``` +USAGE + $ ctv cache [-c] + +FLAGS + -c, --clear + +DESCRIPTION + Cache management command. + Cache dir: C:\Users\10747\AppData\Local\ctv + +EXAMPLES + $ ctv cache + + $ ctv cache --help +``` + +_See code: [src/commands/cache.ts](https://github.com/4746/transverto/blob/v0.0.1/src/commands/cache.ts)_ + +## `ctv help [COMMANDS]` + +Display help for ctv. + +``` +USAGE + $ ctv help [COMMANDS] [-n] + +ARGUMENTS + COMMANDS Command to show help for. + +FLAGS + -n, --nested-commands Include all nested commands in the output. + +DESCRIPTION + Display help for ctv. +``` + +_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.0.10/src/commands/help.ts)_ + +## `ctv init` + +Create a default configuration file + +``` +USAGE + $ ctv init [-f] + +FLAGS + -f, --force overwrite an existing file + +DESCRIPTION + Create a default configuration file + +EXAMPLES + $ ctv init + + $ ctv init --help + + $ ctv init --force +``` + +_See code: [src/commands/init.ts](https://github.com/4746/transverto/blob/v0.0.1/src/commands/init.ts)_ + +## `ctv label [ADD] [DELETE] [GET] [REPLACE] [SYNC]` + +Represents a label management command. + +``` +USAGE + $ ctv label [ADD] [DELETE] [GET] [REPLACE] [SYNC] + +ARGUMENTS + ADD Adds a new label. + DELETE Deletes a label. + GET Retrieves the labels. + REPLACE Replaces a label with the given value. + SYNC A command to update labels synchronously. + +DESCRIPTION + Represents a label management command. +``` + +_See code: [src/commands/label/index.ts](https://github.com/4746/transverto/blob/v0.0.1/src/commands/label/index.ts)_ + +## `ctv label:add [LABEL]` + +Add a new label + +``` +USAGE + $ ctv label:add [LABEL] [-f ] [--noAutoTranslate] [--silent] [-t ] + +ARGUMENTS + LABEL A label key + +FLAGS + -f, --fromLangCode= The language code of source text. + -t, --translation= Translation + --noAutoTranslate + --silent + +DESCRIPTION + Add a new label + +EXAMPLES + $ ctv label:add "hello.world" -f="en" + + $ ctv label:add "hello.world" -f en -t "Hello World!" + + $ ctv label:add "hello.world" -fen -t "Hello World!" + + $ ctv label:add "hello.world" -t "Hello World!" +``` + +_See code: [src/commands/label/add.ts](https://github.com/4746/transverto/blob/v0.0.1/src/commands/label/add.ts)_ + +## `ctv label:delete LABEL` + +Delete the specified label. + +``` +USAGE + $ ctv label:delete LABEL [-r] + +ARGUMENTS + LABEL A label key + +FLAGS + -r, --noReport + +DESCRIPTION + Delete the specified label. + +EXAMPLES + $ ctv label:delete hello + + $ ctv label:delete hello.world +``` + +_See code: [src/commands/label/delete.ts](https://github.com/4746/transverto/blob/v0.0.1/src/commands/label/delete.ts)_ + +## `ctv label:replace LABEL` + +Replace the label + +``` +USAGE + $ ctv label:replace LABEL -t [-f ] + +ARGUMENTS + LABEL A label key + +FLAGS + -f, --langCode= The language code of source text. + -t, --translation= (required) The translation text. + +DESCRIPTION + Replace the label + +EXAMPLES + $ ctv label:replace --help + + $ ctv label:replace hello.world -t="Hello world!!!" + + $ ctv label:replace hello.world -t="Hello world!!!" -fen +``` + +_See code: [src/commands/label/replace.ts](https://github.com/4746/transverto/blob/v0.0.1/src/commands/label/replace.ts)_ + +## `ctv label:sync` + +Synchronizing tags in translation files... + +``` +USAGE + $ ctv label:sync [--autoTranslate] [-r] [-s] + +FLAGS + -r, --noReport + -s, --silent + --autoTranslate + +DESCRIPTION + Synchronizing tags in translation files... + +EXAMPLES + $ ctv label:sync + + $ ctv label:sync "hello.world" -f="en" +``` + +_See code: [src/commands/label/sync.ts](https://github.com/4746/transverto/blob/v0.0.1/src/commands/label/sync.ts)_ + diff --git a/bin/dev.cmd b/bin/dev.cmd new file mode 100644 index 0000000..cec553b --- /dev/null +++ b/bin/dev.cmd @@ -0,0 +1,3 @@ +@echo off + +node --loader ts-node/esm --no-warnings=ExperimentalWarning "%~dp0\dev" %* diff --git a/bin/dev.js b/bin/dev.js new file mode 100644 index 0000000..6dfeff7 --- /dev/null +++ b/bin/dev.js @@ -0,0 +1,9 @@ +#!/usr/bin/env -S node --loader ts-node/esm --no-warnings=ExperimentalWarning + +// eslint-disable-next-line node/shebang +async function main() { + const {execute} = await import('@oclif/core') + await execute({development: true, dir: import.meta.url}) +} + +await main() diff --git a/bin/run.cmd b/bin/run.cmd new file mode 100644 index 0000000..968fc30 --- /dev/null +++ b/bin/run.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\run" %* diff --git a/bin/run.js b/bin/run.js new file mode 100644 index 0000000..39d13f5 --- /dev/null +++ b/bin/run.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +// eslint-disable-next-line node/shebang +async function main() { + const {execute} = await import('@oclif/core') + await execute({dir: import.meta.url}) +} + +await main() diff --git a/package.json b/package.json new file mode 100644 index 0000000..dbcd237 --- /dev/null +++ b/package.json @@ -0,0 +1,113 @@ +{ + "name": "@cli107/transverto", + "description": "CLI transverto label management", + "keywords": [ + "transverto", + "translate", + "label", + "json", + "ctv", + "label management", + "translator" + ], + "author": "Vadim", + "license": "MIT", + "version": "1.0.0", + "bugs": "https://github.com/4746/transverto/issues", + "homepage": "https://github.com/4746/transverto", + "repository": "4746/transverto", + "types": "dist/index.d.ts", + "exports": "./dist/index.js", + "type": "module", + "scripts": { + "build": "shx rm -rf dist && tsc -b", + "lint": "eslint ./src --ext .ts", + "postpack": "shx rm -f oclif.manifest.json", + "posttest": "npm run lint", + "prepack": "npm run build && oclif manifest && oclif readme", + "prepare": "npm run build", + "test": "mocha --forbid-only \"test/**/*.test.ts\"", + "version": "oclif readme && git add README.md", + "win:delete:node_modules": "rd /s /q \"node_modules\"", + "npm:cache:clean": "npm cache clean --force", + "generate-config:bing": "node scripts/bing.generate-config.mjs", + "generate-config:terraprint": "node scripts/terraprint.generate-config.mjs" + }, + "bin": { + "ctv": "./bin/run.js" + }, + "dependencies": { + "@inquirer/prompts": "^3.3.0", + "@oclif/core": "^3.16.0", + "@oclif/plugin-help": "^6.0.10", + "chalk": "^5.3.0", + "fs-extra": "^11.2.0", + "got": "^14.0.0", + "listr2": "^8.0.1" + }, + "devDependencies": { + "@oclif/prettier-config": "^0.2.1", + "@oclif/test": "^3.1.10", + "@types/chai": "^4.3.11", + "@types/cli-progress": "^3.11.5", + "@types/fs-extra": "^11.0.4", + "@types/mocha": "^9.0.0", + "@types/node": "^18.19.5", + "chai": "^4.4.0", + "cheerio": "^1.0.0-rc.12", + "eslint": "^8.56.0", + "eslint-config-oclif": "^5.0.0", + "eslint-config-oclif-typescript": "^3.0.35", + "eslint-config-prettier": "^9.1.0", + "mocha": "^10.2.0", + "oclif": "^4.2.0", + "shx": "^0.3.4", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "/bin", + "/dist", + "/oclif.manifest.json" + ], + "main": "dist/index.js", + "oclif": { + "bin": "ctv", + "dirname": "ctv", + "commands": "./dist/commands", + "scope": "@cli107", + "plugins": [ + "@oclif/plugin-help" + ], + "topicSeparator": ":", + "topics": { + "translate": { + "description": "A simple Translator." + }, + "label": { + "description": "Label management command." + }, + "cache": { + "description": "Cache management command." + }, + "init": { + "description": "Create a default configuration file" + } + }, + "additionalVersionFlags": [ + "-v" + ], + "additionalHelpFlags": [ + "-h" + ], + "warn-if-update-available": { + "timeoutInDays": 7 + } + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/" + } +} diff --git a/scripts/bing.generate-config.mjs b/scripts/bing.generate-config.mjs new file mode 100644 index 0000000..9657611 --- /dev/null +++ b/scripts/bing.generate-config.mjs @@ -0,0 +1,113 @@ +import fs from "node:fs"; +import path from "node:path"; +import got from "got"; +import * as cheerio from 'cheerio'; +import chalk from "chalk"; +import {DEFAULT_USER_AGENT} from "../src/shared/constants.js"; + +// eslint-disable-next-line unicorn/prefer-top-level-await +;(async () => { + const {body: eptBody} = await got('https://bing.com/translator?edgepdftranslator=1', { + headers: { + 'Accept-Language': 'en-US,en', + 'User-Agent': DEFAULT_USER_AGENT + } + }); + let $ = cheerio.load(eptBody) + const eptLangOptions = $('#t_tgtAllLang').children('option') + const eptLangCodes = [] + for (let i = 0, len = eptLangOptions.length, option; i < len; i++) { + option = $(eptLangOptions[i]) + eptLangCodes.push(option.attr('value')) + } + + const parseRichTranslateParams = (body) => JSON.parse( + body.match(/params_RichTranslate\s?=\s?([^;]+);/)[1].replace(/,]$/, ']') + ) + + const {body} = await got('https://bing.com/translator', { + headers: { + 'Accept-Language': 'en-US,en', + 'User-Agent': DEFAULT_USER_AGENT + } + }) + + // fetch config + const richTranslateParams = parseRichTranslateParams(body) + // EPT config + const eptRichTranslateParams = parseRichTranslateParams(eptBody) + + const config = { + websiteEndpoint: richTranslateParams[1], + translateEndpoint: richTranslateParams[0], + spellCheckEndpoint: richTranslateParams[33], + // maxTextLen: richTranslateParams[5], + // PENDING: hard-coding + maxTextLen: 1000, + // PENDING: hard-coding + maxTextLenCN: 5000, + maxCorrectableTextLen: richTranslateParams[30], + maxEPTTextLen: eptRichTranslateParams[5], + correctableLangs: richTranslateParams[31], + eptLangs: eptLangCodes, + userAgent: DEFAULT_USER_AGENT + } + + const data = [ + '/* eslint-disable */', + `/**\n * DO NOT EDIT!\n * THIS IS AUTOMATICALLY GENERATED FILE\n */\n`, + `export class BaseEngineBing {`, + ` protected readonly maxTextLen = 1000;`, + ` protected readonly maxTextLenCN = 5000;`, + ` protected readonly maxCorrectableTextLen = ${richTranslateParams[30]};`, + ` protected readonly maxEPTTextLen = ${eptRichTranslateParams[5]};`, + ` protected readonly userAgent = '${DEFAULT_USER_AGENT}';`, + ` protected readonly websiteEndpoint = 'https://{s}bing.com${richTranslateParams[1]}';`, + ` protected readonly translateEndpoint = 'https://{s}bing.com${richTranslateParams[0]}';`, + ` protected readonly spellCheckEndpoint = 'https://{s}bing.com${richTranslateParams[33]}';`, + ` protected readonly correctableLangCode = ${JSON.stringify(richTranslateParams[31].sort())};`, + ` protected readonly eptLangCode = ${JSON.stringify(eptLangCodes.sort())};`, + `}`, + '' + ] + + await fs.promises.writeFile( + path.resolve('src/shared/engines/base-engine-bing.ts'), + data.join('\n'), + { + flag: 'w', + charset: 'utf-8' + } + ) + + console.log(chalk.green('✔️ Generated class BaseEngineBing')) + + // fetch supported languages + $ = cheerio.load(body) + const langOptions = $('#t_tgtAllLang').children('option') + const langMap = {} + for (let i = 0, len = langOptions.length, option; i < len; i++) { + option = $(langOptions[i]) + langMap[option.attr('value')] = option.text().trim() + } + + await fs.promises.writeFile( + path.resolve('src/shared/lang.bing.ts'), + [ + '/* eslint-disable */', + `/**\n * DO NOT EDIT!\n * THIS IS AUTOMATICALLY GENERATED FILE\n */\n`, + 'export const BING_LANG_MAP = ' + JSON.stringify(langMap, null, 2), + '\n', + 'export type TBingLangCode = keyof typeof BING_LANG_MAP;', + 'export type TBingLangCodeExtend = keyof typeof BING_LANG_MAP | \'auto-detect\' | string;', + 'export type TBingLangCodeName = typeof BING_LANG_MAP[TBingLangCode];', + '' + ].join('\n'), + { + flag: 'w', + charset: 'utf-8' + } + ) + + console.log(chalk.green('✔️ Generated const BING_LANG_MAP')) +})() diff --git a/scripts/terraprint.generate-config.mjs b/scripts/terraprint.generate-config.mjs new file mode 100644 index 0000000..feb773a --- /dev/null +++ b/scripts/terraprint.generate-config.mjs @@ -0,0 +1,45 @@ +import fs from "node:fs"; +import path from "node:path"; +import got from "got"; +import * as cheerio from 'cheerio'; +import chalk from "chalk"; +import {DEFAULT_USER_AGENT} from "../src/shared/constants.js"; + +// eslint-disable-next-line unicorn/prefer-top-level-await +;(async () => { + const languages = await got('https://translate.terraprint.co/languages', { + headers: { + 'Accept-Language': 'en-US,en', + 'User-Agent': DEFAULT_USER_AGENT + }, + }).json(); + + if (!Array.isArray(languages)) { + throw new Error('Load languages') + } + + let langMap = {} + languages.forEach(({code, name}) => { + langMap[code] = name; + }); + + await fs.promises.writeFile( + path.resolve('src/shared/lang.terra.ts'), + [ + '/* eslint-disable */', + `/**\n * DO NOT EDIT!\n * THIS IS AUTOMATICALLY GENERATED FILE\n */\n`, + 'export const TERRA_LANG_MAP = ' + JSON.stringify(langMap, null, 2), + '\n', + 'export type TTerraLangCode = keyof typeof TERRA_LANG_MAP;', + 'export type TTerraLangCodeExtend = keyof typeof TERRA_LANG_MAP | \'auto\' | string;', + 'export type TTerraLangCodeName = typeof TERRA_LANG_MAP[TTerraLangCode];', + '' + ].join('\n'), + { + flag: 'w', + charset: 'utf-8' + } + ) + + console.log(chalk.green('✔️ Generated const TERRA_LANG_MAP')) +})() diff --git a/src/commands/cache.ts b/src/commands/cache.ts new file mode 100644 index 0000000..5fe9b0e --- /dev/null +++ b/src/commands/cache.ts @@ -0,0 +1,62 @@ +import {Flags} from '@oclif/core' +import chalk from "chalk"; +import fs from "node:fs"; +import path from "node:path"; + +import {CTV_CACHE_ENGINE_FILE} from "../shared/constants.js"; +import {Helper} from "../shared/helper.js"; +import {LabelBaseCommand} from "../shared/label-base.command.js"; + +/** + * node --loader ts-node/esm --no-warnings=ExperimentalWarning ./bin/dev cache + * node --loader ts-node/esm --no-warnings=ExperimentalWarning ./bin/dev cache --help + */ +export default class Cache extends LabelBaseCommand { + static description = 'Cache management command. \nCache dir: <%= config.cacheDir %>' + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --help', + ] + + static flags = { + clear: Flags.boolean({char: 'c'}), + } + + private cacheEngineFile: string; + + public async run(): Promise { + const {flags} = await this.parse(Cache) + + this.cacheEngineFile = path.join(this.config.cacheDir, CTV_CACHE_ENGINE_FILE); + + if (flags.clear) { + await this.cacheClear(); + this.log(chalk.green(`Cache cleared successfully.`)); + } + + const size = await this.cacheSize(); + + this.log(chalk.cyan(`Cache path: ${this.cacheEngineFile}`)); + this.log(chalk.cyan(`Size: ${size}KB`)); + } + + private async cacheClear() { + return Helper.writeOrCreateJsonFile({}, this.cacheEngineFile); + } + + private async cacheSize() { + const stat = fs.statSync(this.cacheEngineFile, {throwIfNoEntry: false}); + + if (!stat) { + await this.cacheClear(); + return 0; + } + + if (stat.isFile()) { + return Math.round(stat.size / 1024); + } + + this.error(chalk.red(`Error path cache path: ${this.cacheEngineFile}`)); + } +} diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..53deb32 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,51 @@ +import {Flags} from '@oclif/core' +import chalk from "chalk"; +import fs from "node:fs"; +import path from "node:path"; + +import {CONFIG_DEFAULT} from "../shared/config.js"; +import {CTV_CONFIG_FILE_NAME} from "../shared/constants.js"; +import {Helper} from "../shared/helper.js"; +import {LabelBaseCommand} from "../shared/label-base.command.js"; + +/** + * node --loader ts-node/esm --no-warnings=ExperimentalWarning ./bin/dev init + * node --loader ts-node/esm --no-warnings=ExperimentalWarning ./bin/dev init --help + */ +export default class Init extends LabelBaseCommand { + static description = 'Create a default configuration file' + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --help', + '<%= config.bin %> <%= command.id %> --force', + ] + + static flags = { + force: Flags.boolean({char: 'f', description: 'overwrite an existing file'}), + } + + public async run(): Promise { + const {flags} = await this.parse(Init) + + const configPath = path.join(process.cwd(), CTV_CONFIG_FILE_NAME); + + const stat = fs.statSync(configPath, {throwIfNoEntry: false}); + + if (stat) { + if (!stat.isFile()) { + return this.log(chalk.red(`Error path config path: ${configPath}`)); + } else if (!flags.force) { + return this.log(chalk.yellow(`The settings file already exists: ${configPath}`)); + } + } + + const data = {...CONFIG_DEFAULT}; + delete data.terra; + + await Helper.writeJsonFile(data, configPath); + + this.log(chalk.green(`Successfully!`)); + this.log(chalk.cyan(`Open the file "${CTV_CONFIG_FILE_NAME}" and set the appropriate parameters.`)); + } +} diff --git a/src/commands/label/add.ts b/src/commands/label/add.ts new file mode 100644 index 0000000..6325d32 --- /dev/null +++ b/src/commands/label/add.ts @@ -0,0 +1,120 @@ +import {confirm} from "@inquirer/prompts"; +import {Args, Flags} from '@oclif/core' +import chalk from "chalk"; + +import {Helper} from "../../shared/helper.js"; +import {LabelBaseCommand} from "../../shared/label-base.command.js"; +import {UTIL} from "../../shared/util.js"; + +/** + * node --loader ts-node/esm --no-warnings=ExperimentalWarning ./bin/dev label:add hello.world -f="en" + * node --loader ts-node/esm --no-warnings=ExperimentalWarning ./bin/dev label:add hello.world -f="en" -t "Hello World!" + */ +export default class LabelAdd extends LabelBaseCommand { + static args = { + label: Args.string({default: null, description: 'A label key', requiredOrDefaulted: true}), + } + + static description = 'Add a new label'; + + static examples = [ + `<%= config.bin %> <%= command.id %> "hello.world" -f="en"`, + `<%= config.bin %> <%= command.id %> "hello.world" -f en -t "Hello World!"`, + `<%= config.bin %> <%= command.id %> "hello.world" -fen -t "Hello World!"`, + `<%= config.bin %> <%= command.id %> "hello.world" -t "Hello World!"`, + ] + + static flags = { + fromLangCode: Flags.string({ + char: 'f', + default: null, + description: 'The language code of source text.', + multiple: false, + requiredOrDefaulted: true, + }), + noAutoTranslate: Flags.boolean({aliases: ['no-auto-translate'], default: false}), + silent: Flags.boolean({default: false}), + translation: Flags.string({ + char: "t", + default: null, + description: 'Translation', + multiple: false, + requiredOrDefaulted: true, + }), + } + + /** + * The language code of source text. + */ + private fromLangCode: string; + private label: string; + private noAutoTranslate: boolean; + private silent: boolean; + private translation: string; + + public async run(): Promise { + const {args, flags} = await this.parse(LabelAdd); + + this.noAutoTranslate = flags.noAutoTranslate; + this.silent = flags.silent; + + await this.readCliConfig(); + + this.label = await this.getLabelValidationOrInput({ + label: args.label || null, + labelValidation: this.cliConfig.labelValidation + }); + + this.fromLangCode = await this.getLangCode(this.cliConfig.languages, flags.fromLangCode, this.cliConfig.langCodeDefault); + + this.translation = await this.getTranslation(flags.translation, this.fromLangCode); + + const i18nPath = Helper.getPathLanguageFile(this.fromLangCode, this.cliConfig.basePath); + + let dataJson: NonNullable; + + try { + dataJson = await Helper.readJsonFile(i18nPath); + } catch (reason) { + // https://en.wikipedia.org/wiki/Errno.h + if (reason?.code === 'ENOENT') { + // No such file or directory + const answer = await confirm({ + message: chalk.yellow(`The file [${i18nPath}] does not exist. \nCreate it?`), + }, { + clearPromptOnDone: true + }); + + if (!answer) { + this.log(chalk.green(`The file [${i18nPath}] is missing!`)) + return this.exit(); + } + + dataJson = {}; + await Helper.writeOrCreateJsonFile(dataJson, i18nPath); + } else { + throw reason; + } + } + + UTIL.setNestedValue(dataJson, this.label, this.translation); + + await Helper.writeOrCreateJsonFile(dataJson, i18nPath); + + const param = [`--silent`, `--noReport`]; + + if (!this.noAutoTranslate) { + param.push(`--auto-translate`) + } + + if (!this.silent) { + this.log(chalk.green(`Enter label:`), this.label); + this.log(chalk.green(`Enter language:`), this.fromLangCode); + this.log(chalk.green(`Enter translation:`), this.translation); + } + + await this.config.runCommand('label:sync', param); + + this.log(chalk.cyan(`Done!`)); + } +} diff --git a/src/commands/label/delete.ts b/src/commands/label/delete.ts new file mode 100644 index 0000000..3679bdd --- /dev/null +++ b/src/commands/label/delete.ts @@ -0,0 +1,131 @@ +import {Args, Flags, ux} from '@oclif/core' +import chalk from "chalk"; + +import {ILabelDeleteRowReport} from "../../shared/entities/report.js"; +import {Helper} from "../../shared/helper.js"; +import {LabelBaseCommand} from "../../shared/label-base.command.js"; +import {UTIL} from "../../shared/util.js"; + +/** + * node --loader ts-node/esm --no-warnings=ExperimentalWarning ./bin/dev label:delete hello.world + */ +export default class LabelDelete extends LabelBaseCommand { + static args = { + label: Args.string({ + description: 'A label key', + required: true + }), + } + + static description = 'Delete the specified label.' + + static examples = [ + `<%= config.bin %> <%= command.id %> hello`, + `<%= config.bin %> <%= command.id %> hello.world`, + ]; + + static flags = { + noReport: Flags.boolean({aliases: ['no-report'], char: 'r', default: false}), + } + + private label: string; + private noReport: boolean; + + private reportRows: ILabelDeleteRowReport[] = []; + + public async run(): Promise { + const {args, flags} = await this.parse(LabelDelete); + + await this.readCliConfig(); + this.label = args.label; + this.noReport = flags.noReport; + + const mapLang = await this.getTranslationLanguages(); + + let code: string; + for (code in mapLang) { + let isChange = false; + const reportRow: ILabelDeleteRowReport = { + code, + labels: [], + status: '' + }; + if (this.label in mapLang[code].translateFlatten) { + mapLang[code].translate = UTIL.deletePropertyPath(mapLang[code].translate, this.label); + isChange = true; + + reportRow.status = '*'; + } else { + for (const labelKey in mapLang[code].translateFlatten) { + if (isChange) { + // eslint-disable-next-line max-depth + if (labelKey.startsWith(`${this.label}.`)) { + reportRow.labels.push(labelKey); + } + + continue; + } + + if (labelKey.startsWith(`${this.label}.`)) { + mapLang[code].translate = UTIL.deletePropertyPath(mapLang[code].translate, this.label); + isChange = true; + + reportRow.status = '-'; + + reportRow.labels.push(labelKey); + } + } + } + + if (isChange) { + await Helper.writeOrCreateJsonFile(UTIL.sortObjByKey(mapLang[code].translate), mapLang[code].file); + } + + this.reportRows.push(reportRow); + } + + if (code) { + await this.makeTranslationEnum({ + ...mapLang[code], + translateFlatten: UTIL.flattenObject(mapLang[code].translate) + }); + } + + this.showReport(); + + this.log(chalk.cyan(`Done!`)); + } + + private showReport() { + if (this.noReport) { + return; + } + + ux.table>(this.reportRows, { + code: { + get: (row) => { + return chalk.green(row.code); + }, + header: 'Lang', + minWidth: 7, + }, + status: { + get: (row) => { + return chalk.cyan(row.status); + }, + header: 'Deleted', + minWidth: 7, + }, + // eslint-disable-next-line + labels: { + get: (row) => { + return chalk.yellow(row.labels.join(', ')); + }, + header: 'Labels', + minWidth: 10 + } + }, { + 'no-truncate': true + }) + } +} diff --git a/src/commands/label/index.ts b/src/commands/label/index.ts new file mode 100644 index 0000000..5a469c3 --- /dev/null +++ b/src/commands/label/index.ts @@ -0,0 +1,17 @@ +import {Args, Command} from '@oclif/core' + +export default class Label extends Command { + static args = { + add: Args.string({description: 'Adds a new label.', required: false}), + delete: Args.string({description: 'Deletes a label.', required: false}), + get: Args.string({description: 'Retrieves the labels.', required: false}), + replace: Args.string({description: 'Replaces a label with the given value.', required: false}), + sync: Args.string({description: 'A command to update labels synchronously.', required: false}), + } + + static description = 'Represents a label management command.' + + async run(): Promise { + // const {args} = await this.parse(Label) + } +} diff --git a/src/commands/label/replace.ts b/src/commands/label/replace.ts new file mode 100644 index 0000000..45c3255 --- /dev/null +++ b/src/commands/label/replace.ts @@ -0,0 +1,76 @@ +import {Args, Flags} from '@oclif/core' +import chalk from "chalk"; + +import {Helper} from "../../shared/helper.js"; +import {LabelBaseCommand} from "../../shared/label-base.command.js"; +import {UTIL} from "../../shared/util.js"; + +/** + * node --loader ts-node/esm --no-warnings=ExperimentalWarning ./bin/dev label:replace hello.world + */ +export default class LabelReplace extends LabelBaseCommand { + static args = { + label: Args.string({ + description: 'A label key', + required: true + }), + } + + static description = 'Replace the label' + + static examples = [ + '<%= config.bin %> <%= command.id %> --help', + '<%= config.bin %> <%= command.id %> hello.world -t="Hello world!!!"', + '<%= config.bin %> <%= command.id %> hello.world -t="Hello world!!!" -fen', + ] + + static flags = { + langCode: Flags.string({ + char: 'f', + default: null, + description: 'The language code of source text.', + multiple: false, + requiredOrDefaulted: true, + }), + translation: Flags.string({ + char: 't', + description: 'The translation text.', + required: true, + }), + } + + private label: string; + private langCode: string; + private translation: string; + + public async run(): Promise { + const {args, flags} = await this.parse(LabelReplace) + + await this.readCliConfig(); + + this.label = await this.getLabelValidationOrInput({ + label: args.label, + labelValidation: this.cliConfig.labelValidation + }); + + this.langCode = await this.getLangCode(this.cliConfig.languages, flags.langCode); + + this.translation = await this.getTranslation(flags.translation, this.langCode); + + const mapLang = await this.getLangTranslation(this.langCode); + + let isChange = false; + if (this.label in mapLang.translateFlatten) { + mapLang.translate = UTIL.replacePropertyPath(mapLang.translate, this.label, flags.translation); + isChange = true; + } else { + this.log(chalk.red(`Label not found.`)); + } + + if (isChange) { + await Helper.writeOrCreateJsonFile(mapLang.translate, mapLang.file); + + this.log(chalk.green(`Label changed successfully.`)); + } + } +} diff --git a/src/commands/label/sync.ts b/src/commands/label/sync.ts new file mode 100644 index 0000000..c6f582b --- /dev/null +++ b/src/commands/label/sync.ts @@ -0,0 +1,257 @@ +import {Flags, ux} from '@oclif/core' +import chalk from "chalk"; +import {Listr} from 'listr2'; + +import {TranslateEngine} from "../../shared/engines/translate.engine.js"; +import {ISyncRowReport} from "../../shared/entities/report.js"; +import {TTranslation} from "../../shared/entities/translate.js"; +import {Helper} from "../../shared/helper.js"; +import {LabelBaseCommand} from "../../shared/label-base.command.js"; +import {UTIL} from "../../shared/util.js"; + +/** + * node --loader ts-node/esm --no-warnings=ExperimentalWarning ./bin/dev label:sync + * node --loader ts-node/esm --no-warnings=ExperimentalWarning ./bin/dev label:sync --help + * node --loader ts-node/esm --no-warnings=ExperimentalWarning ./bin/dev label:sync --auto-translate + */ +export default class LabelSync extends LabelBaseCommand { + static description = 'Synchronizing tags in translation files...' + + static examples = [ + '<%= config.bin %> <%= command.id %>', + `<%= config.bin %> <%= command.id %> "hello.world" -f="en"`, + ] + + static flags = { + autoTranslate: Flags.boolean({aliases: ['auto-translate'], default: false}), + noReport: Flags.boolean({aliases: ['no-report'], char: 'r', default: false}), + silent: Flags.boolean({char: 's', default: false}), + } + + private autoTranslate: boolean; + + private langCodePriority: Record; + private noReport: boolean; + + private repostRows: ISyncRowReport[] = []; + private repostTableHeader: ISyncRowReport = { + label: 'Label', + }; + + private silent: boolean; + private translateEngine: TranslateEngine; + + public async run(): Promise { + const {flags} = await this.parse(LabelSync) + + this.noReport = flags.noReport; + this.silent = flags.silent; + + this.autoTranslate = flags.autoTranslate; + + const tasks = new Listr([ + { + task: async () => this.taskReadConfiguration(), + title: 'Configuration settings', + }, + { + task: async (ctx) => this.taskReadLanguageFiles(ctx), + title: 'Read language files', + }, + { + // skip: ctx => ctx.isSynchronized, + task: async (ctx) => this.taskLabelAreSynchronized(ctx), + title: 'Labels are synchronized', + }, + { + // skip: ctx => ctx.isSynchronized, + task: async (ctx) => this.taskCreateTranslationEnum(ctx), + title: 'Create translation Enum', + } + ], { + concurrent: false, + exitOnError: true, + silentRendererCondition: this.silent + }); + + // tasks.add([]) + + tasks.run() + .then(() => { + this.showReport(); + + if (!this.silent) { + this.log(chalk.cyan(`Done!`)) + } + }); + } + + /** + * Adds a report row to the report table. + */ + private addReportRow(row: Record) { + const fill = {...this.repostTableHeader}; + Object.keys(fill).forEach(key => { + fill[key] = ''; + }); + + this.repostRows.push({ + ...fill, + ...row, + }) + } + + private createLangCodePriority(languages: string[]): Record { + const langCodePriority: [string, number][] = languages.map((code, idx) => { + this.repostTableHeader[code] = ''; + return [code, idx]; + }); + + return Object.fromEntries( + (new Map(langCodePriority)).entries() + ); + } + + private async flattenAddMissingKeys(sourceLangCode: string, targetLangCode: string, targetTranslate: Record, sourceTranslate: Record) { + const added: Set = new Set(); + for (const label in sourceTranslate) { + if (label in targetTranslate) { + continue; + } + + added.add(label); + + targetTranslate[label] = await this.translateText(sourceTranslate[label], sourceLangCode, targetLangCode); + + this.addReportRow({ + label: label, + [sourceLangCode]: '*', + [targetLangCode]: '+' + }) + } + + return added; + } + + private showReport() { + if (this.noReport) { + return; + } + + if (this.repostRows.length === 0) { + return; + } + + const mapRows: Map = new Map(); + /** + * group label repeats + */ + this.repostRows.forEach((row) => { + if (mapRows.has(row.label)) { + Object.keys(row).forEach(key => { + if (['', '*'].includes(row[key])) { + delete row[key]; + } + }); + + mapRows.set(row.label, {...mapRows.get(row.label), ...row}); + } else { + mapRows.set(row.label, row); + } + }); + + const columnLangCode = this.cliConfig.languages.reduce((acc, code) => { + acc[code] = { + get: (row) => { + if (row[code] === '*') { + return chalk.green(row[code]) + } + + return chalk.red(row[code]); + }, + header: code, + minWidth: 7, + } + return acc; + }, {}) + + ux.table>([...mapRows.values()].map((v, k) => ({ + ...v, id: (k + 1).toString(), + })), { + id: { + header: '#', + minWidth: 7, + }, + ...columnLangCode, + label: { + get: (row) => { + return chalk.cyan(row.label); + }, + header: 'Label', + minWidth: 20, + } + }, { + 'no-truncate': true + }) + } + + private async taskCreateTranslationEnum(ctx: CtxTasks) { + const lc = Object.keys(ctx.mapLang)[0]; + + await this.makeTranslationEnum(ctx.mapLang[lc]) + } + + private async taskLabelAreSynchronized(ctx: CtxTasks) { + for (const currentLang in ctx.mapLang) { + const restLanguages = [...this.cliConfig.languages]; + + restLanguages.splice(restLanguages.indexOf(currentLang), 1); + restLanguages.sort((a, b) => { + return this.langCodePriority[a] - this.langCodePriority[b]; + }); + + for (const sourceLang of restLanguages) { + const added: Set = await this.flattenAddMissingKeys( + sourceLang, + currentLang, + ctx.mapLang[currentLang].translateFlatten, + ctx.mapLang[sourceLang].translateFlatten + ); + + added.forEach((label) => { + UTIL.setNestedValue(ctx.mapLang[currentLang].translate, label, ctx.mapLang[currentLang].translateFlatten[label]) + }) + } + + await Helper.writeOrCreateJsonFile(UTIL.sortObjByKey(ctx.mapLang[currentLang].translate), ctx.mapLang[currentLang].file); + } + } + + private async taskReadConfiguration() { + await this.readCliConfig(); + + this.translateEngine = new TranslateEngine({...this.cliConfig}, this.config.cacheDir); + + this.langCodePriority = this.createLangCodePriority([...this.cliConfig.languages]); + } + + private async taskReadLanguageFiles(ctx: CtxTasks) { + ctx.mapLang = await this.getTranslationLanguages(); + } + + private async translateText(text: string | string[], sourceLangCode: string, targetLangCode: string) { + if (this.autoTranslate && !Array.isArray(text)) { + return this.translateEngine.translateText({ + from: sourceLangCode, + text, + to: targetLangCode + }); + } + + return text; + } +} + +interface CtxTasks { + mapLang: TTranslation; +} diff --git a/src/commands/translate/bing.ts b/src/commands/translate/bing.ts new file mode 100644 index 0000000..d8aa55b --- /dev/null +++ b/src/commands/translate/bing.ts @@ -0,0 +1,73 @@ +import {Args, Flags} from '@oclif/core' + +import {BingEngine} from "../../shared/engines/bing.engine.js"; +import {IBingTranslationResult} from "../../shared/entities/bing.config.js"; +import {LabelBaseCommand} from "../../shared/label-base.command.js"; +import {TBingLangCode, TBingLangCodeExtend} from "../../shared/lang.bing.js"; + + +/** + * Represents a TranslateBing command that extends the Command class. + * + * node --loader ts-node/esm ./bin/dev translate:bing --help + * node --loader ts-node ./bin/dev translate:bing --help + * node --loader ts-node ./bin/dev translate:bing + * node --loader ts-node ./bin/dev translate:bing Text + */ +export default class TranslateBing extends LabelBaseCommand { + static args = { + text: Args.string({description: 'The text to be translated, can\'t be blank. The maximum text length is 1000.', required: true}), + } + + static description = 'A simple and free API for Bing Translator.' + + static enableJsonFlag = true; + static examples = [ + '<%= config.bin %> <%= command.id %> Hello', + '<%= config.bin %> <%= command.id %> Hello --json', + '<%= config.bin %> <%= command.id %> --help', + ] + + static flags = { + correct: Flags.boolean({char: 'c', default: false, description: '[default: false] Whether to correct the input text.'}), + from: Flags.string({char: 'f', default: BingEngine.defaultFrom, description: 'The language code of source text.'}), + raw: Flags.boolean({char: 'r', default: false, description: '[default: false] Whether the translation result contains raw response from Bing API.'}), + to: Flags.string({char: 't', default: BingEngine.defaultTo, description: 'The language in which the text should be translated.'}), + userAgent: Flags.string({default: null, description: 'The header value of `User-Agent` used in API requests.'}), + } + + static hidden = true; + + /** + * source language code. `auto-detect` by default. + */ + private from: TBingLangCodeExtend; + /** + * content to be translated + */ + private text: string; + /** + * target language code. `en` by default. + */ + private to: TBingLangCode; + + public async run(): Promise { + const {args, flags} = await this.parse(TranslateBing) + + await this.readCliConfig() + + this.text = args.text.trim(); + this.from = (flags.from || 'auto-detect') as TBingLangCodeExtend; + this.to = (flags.to || 'en') as TBingLangCode; + + const be = new BingEngine({...this.cliConfig.bing, correct: flags.correct, raw: flags.raw, userAgent: flags.userAgent}); + + const tr = await be.translate(args.text, this.to, this.from); + + if (this.jsonEnabled()) { + return tr; + } + + this.log(tr.translation); + } +} diff --git a/src/commands/translate/terra.ts b/src/commands/translate/terra.ts new file mode 100644 index 0000000..3152b23 --- /dev/null +++ b/src/commands/translate/terra.ts @@ -0,0 +1,86 @@ +import {select} from "@inquirer/prompts"; +import {Args, Flags} from '@oclif/core' + +import {TerraEngine} from "../../shared/engines/terra.engine.js"; +import {ITerraTranslationResult} from "../../shared/entities/terra.config.js"; +import {LabelBaseCommand} from "../../shared/label-base.command.js"; +import {TERRA_LANG_MAP, TTerraLangCode, TTerraLangCodeExtend} from "../../shared/lang.terra.js"; + +/** + * node --loader ts-node/esm --no-warnings=ExperimentalWarning ./bin/dev translate:terra "Hello World!" -tuk + */ +export default class TranslateTerra extends LabelBaseCommand { + static args = { + text: Args.string({description: 'Text for translation'}), + } + + static description = 'describe the command here' + public static enableJsonFlag = true; + + static examples = [ + `<%= config.bin %> <%= command.id %> --help`, + `<%= config.bin %> <%= command.id %> "Hello World!" -f="en"`, + `<%= config.bin %> <%= command.id %> "Hello World!" -fen -t "uk"`, + `<%= config.bin %> <%= command.id %> "Hello World!" -tuk`, + `<%= config.bin %> <%= command.id %> "Hello World!" -tuk --json`, + ] + + static flags = { + from: Flags.string({ + char: 'f', + default: 'auto', + description: 'Original language code', + multiple: false, + options: Object.keys(TERRA_LANG_MAP), + requiredOrDefaulted: true, + }), + to: Flags.string({ + char: 't', + description: 'Translation language code', + options: Object.keys(TERRA_LANG_MAP), + }), + } + + static hidden = true; + + public async run(): Promise { + const {args, flags} = await this.parse(TranslateTerra) + + const {terra} = await this.readCliConfig(); + const text = args.text || null; + + const from = await this.getLanguage(flags.from) as TTerraLangCodeExtend; + const to = flags.to.toString() as TTerraLangCode; + + const engine = new TerraEngine(terra); + + const tr = await engine.translate(text, to, from); + + if (this.jsonEnabled()) { + return tr; + } + + this.log(tr.translatedText); + } + + + private async getLanguage(flagLanguage?: string) { + let language = TerraEngine.getLangCode(flagLanguage); + + if (language) { + this.log(`? Choose a language: ${language}`) + } + + if (!language) { + language = await select({ + choices: Object.keys(TERRA_LANG_MAP).map( + (lang: string) => ({name: TERRA_LANG_MAP[lang], value: lang}) + ), + default: 'en', + message: 'Choose a language:' + }) + } + + return language + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e32b0b2 --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export {run} from '@oclif/core' diff --git a/src/shared/config.ts b/src/shared/config.ts new file mode 100644 index 0000000..87909ab --- /dev/null +++ b/src/shared/config.ts @@ -0,0 +1,85 @@ +import {DEFAULT_USER_AGENT} from "./constants.js"; +import {IBingConfig} from "./entities/bing.config.js"; +import {ITerraConfig} from "./entities/terra.config.js"; +import {TEngineTranslation} from "./entities/translation.engine.js"; + +export const LANG_CODE_DEFAULT = 'en'; +export const LABEL_VALIDATION_DEFAULT = '^[a-z0-9\\.\\-\\_]{3,100}$'; + +export interface IConfig { + /** + * The base path where is your translation json files + */ + basePath: string; + /** + * The base path for language assets. + */ + basePathEnum: string; + /** + * Configuration options for Bing setting. + */ + bing?: IBingConfig; + engine: TEngineTranslation; + engineUseCache?: false; + /** + * Regular expression pattern for validating labels. + * + * The label should only contain lowercase letters, numbers, periods, hyphens, and underscores. + * It should also be between 3 and 100 characters long. + */ + labelValidation: string; + langCodeDefault: string; + /** + * List languages for cli. + */ + languages: string[]; + nameEnum?: string; + /** + * Configuration options for TerraPrint. + */ + terra?: ITerraConfig; +} + +export const CONFIG_DEFAULT: IConfig = { + /** + * The base path where is your translation json files + */ + basePath: "dist/i18n", + /** + * The base path for language assets. + */ + basePathEnum: 'dist/i18n/language.ts', + /** + * Configuration options for Bing setting. + */ + bing: { + agent: null, + correct: false, + raw: false, + userAgent: DEFAULT_USER_AGENT + }, + engine: "bing", + engineUseCache: false, + /** + * Regular expression pattern for validating labels. + * + * The label should only contain lowercase letters, numbers, periods, hyphens, and underscores. + * It should also be between 3 and 100 characters long. + */ + labelValidation: LABEL_VALIDATION_DEFAULT, + langCodeDefault: LANG_CODE_DEFAULT, + /** + * List languages for cli. + */ + languages: [LANG_CODE_DEFAULT], + nameEnum: "LanguageLabel", + /** + * Configuration options for TerraPrint. + */ + terra: { + apiKey: null, + fromLangCode: 'auto', + toLangCode: 'uk', + userAgent: DEFAULT_USER_AGENT + }, +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts new file mode 100644 index 0000000..7234684 --- /dev/null +++ b/src/shared/constants.ts @@ -0,0 +1,4 @@ +export const CTV_CONFIG_FILE_NAME = '.ctv.config.json'; +export const CTV_CACHE_ENGINE_FILE = 'translate-engine.cache.json'; + +export const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0'; diff --git a/src/shared/engines/base-engine-bing.ts b/src/shared/engines/base-engine-bing.ts new file mode 100644 index 0000000..116db57 --- /dev/null +++ b/src/shared/engines/base-engine-bing.ts @@ -0,0 +1,18 @@ +/* eslint-disable */ +/** + * DO NOT EDIT! + * THIS IS AUTOMATICALLY GENERATED FILE + */ + +export class BaseEngineBing { + protected readonly maxTextLen = 1000; + protected readonly maxTextLenCN = 5000; + protected readonly maxCorrectableTextLen = 50; + protected readonly maxEPTTextLen = 3000; + protected readonly userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0'; + protected readonly websiteEndpoint = 'https://{s}bing.com/translator'; + protected readonly translateEndpoint = 'https://{s}bing.com/ttranslatev3?isVertical=1&'; + protected readonly spellCheckEndpoint = 'https://{s}bing.com/tspellcheckv3?isVertical=1&'; + protected readonly correctableLangCode = ["da","de","en","es","fi","fr","fr-CA","it","ja","ko","nl","no","pl","pt","pt-PT","ru","sv","tr","zh-Hans","zh-Hant"]; + protected readonly eptLangCode = ["af","am","ar","az","bg","bn","bs","ca","cs","cy","da","de","el","en","es","et","fa","fi","fil","fr","ga","gu","he","hi","hr","ht","hu","hy","id","is","it","iu","ja","kk","km","kn","ko","ku","lo","lt","lv","mg","mi","ml","mr","ms","mt","my","nb","ne","nl","or","pa","pl","prs","ps","pt","pt-PT","ro","ru","sk","sl","sm","sq","sr-Cyrl","sr-Latn","sv","sw","ta","te","th","to","tr","uk","ur","vi","zh-Hans","zh-Hant"]; +} diff --git a/src/shared/engines/bing.engine.ts b/src/shared/engines/bing.engine.ts new file mode 100644 index 0000000..c94f395 --- /dev/null +++ b/src/shared/engines/bing.engine.ts @@ -0,0 +1,322 @@ +import {Got, RequestError, Response, got} from "got"; + +import {IBingConfig, IBingFetchConfig, IBingRequestBody, IBingTranslationResult} from "../entities/bing.config.js"; +import {BaseEngine, IParamTranslateText} from "../entities/translation.engine.js"; +import {BING_LANG_MAP, TBingLangCode, TBingLangCodeExtend} from "../lang.bing.js"; +import {BaseEngineBing} from "./base-engine-bing.js"; + +export class BingEngine extends BaseEngineBing implements BaseEngine { + static readonly defaultFrom: TBingLangCodeExtend = 'auto-detect'; + static readonly defaultTo: TBingLangCode = 'en'; + static readonly MAX_RETRY_COUNT = 3; + + private client: Got; + + private gConfig: Partial = {}; + + constructor(private readonly config: IBingConfig) { + super(); + + this.client = got.extend({ + headers: { + referer: this.replaceSubdomain(this.websiteEndpoint), + 'user-agent': this.config?.userAgent || this.userAgent + }, + retry: { + limit: BingEngine.MAX_RETRY_COUNT, + methods: ['GET', 'POST'] + }, + }); + } + + static getLangCode(lang: TBingLangCode | string) { + if (!lang || typeof lang !== 'string') { + return + } + + if (lang in BING_LANG_MAP) { + return lang; + } + + lang = lang.toLowerCase(); + + if (lang in BING_LANG_MAP) { + return lang; + } + + return Object.keys(BING_LANG_MAP).find((code: TBingLangCode | string) => code.toLowerCase() === lang) + } + + public isCorrectable(lang: TBingLangCode | string): boolean { + return this.correctableLangCode.includes(BingEngine.getLangCode(lang)) + } + + public async translate(text: string, toLangCode?: TBingLangCodeExtend, fromLangCode?: TBingLangCodeExtend): Promise { + if (!text || !(text = text.trim())) { + throw new Error(`[E00104] Not text`); + } + + fromLangCode = fromLangCode || BingEngine.defaultFrom; + toLangCode = toLangCode || BingEngine.defaultTo; + + if (!this.isSupported(fromLangCode)) { + throw new Error(`[E00105] The language '${fromLangCode}' is not supported!`) + } + + if (!this.isSupported(toLangCode)) { + throw new Error(`[E00106] The language '${toLangCode}' is not supported!`) + } + + if (Object.keys(this.gConfig).length === 0) { + this.gConfig = await this.makeFetchConfig() + } + + if (this.isTokenExpired()) { + this.gConfig = await this.makeFetchConfig() + } + + const canUseEPT = text.length <= this.maxEPTTextLen && ([fromLangCode, toLangCode].every(lang => lang === 'auto-detect' || this.eptLangCode.includes(lang))) + + const subdomain = this.gConfig.subdomain || null; + + if (!canUseEPT) { + // Currently 5000 is supported only in China + // PENDING: dynamically re-generate local config.json when initializing? + const maxTextLen = subdomain === 'cn' + ? this.maxTextLenCN + : this.maxTextLen + + if (text.length > maxTextLen) { + throw new Error(`[E00120] The supported maximum text length is ${maxTextLen}. Please shorten the text.`) + } + } + + const requestURL = this.makeRequestURL(false, canUseEPT) + const requestBody = this.makeRequestBody(false, text, fromLangCode, toLangCode) + + let body = await this.requestTranslate( + requestURL, + requestBody, + ) + + const translation = body[0].translations[0] + const detectedLang = body[0].detectedLanguage + + const res: IBingTranslationResult = { + language: { + from: detectedLang.language, + score: detectedLang.score, + to: translation.to + }, + text, + translation: translation.text, + userLang: fromLangCode + } + + + if (this.config.correct) { + const correctLang = detectedLang.language + const matcher = text.match(/"/g) + + const len = text.length + (matcher ? matcher.length : 0); + // currently, there is a limit of 50 characters for correction service + // and only parts of languages are supported + // otherwise, it will return status code 400 + if (len <= this.maxCorrectableTextLen && this.isCorrectable(correctLang)) { + body = await this.requestTranslate( + this.makeRequestURL(true), + this.makeRequestBody(true, text, correctLang), + ); + + res.correctedText = body && body.correctedText + } else { + console.warn(`[E00130] The detected language '${correctLang}' is not supported to be corrected or the length of text is more than ${this.maxCorrectableTextLen}.`) + } + } + + if (this.config.raw) { + res.raw = body + } + + return res; + } + + async translateText({from, text, to}: IParamTranslateText) { + const response = await this.translate(text, to, from); + + return response.translation || text; + } + + private isSupported(lang: TBingLangCode | string): boolean { + return BingEngine.getLangCode(lang) !== null + } + + /** + * Refetch global config if token is expired + * @return {boolean} whether token is expired or not + */ + private isTokenExpired(): boolean { + if (Object.keys(this.gConfig).length === 0) { + return true + } + + const { tokenExpiryInterval, tokenTs } = this.gConfig + + return Date.now() - tokenTs > tokenExpiryInterval + } + + private async makeFetchConfig(): Promise { + let subdomain = this.gConfig.subdomain || null; + + try { + const {body, redirectUrls} = await this.client.get(this.replaceSubdomain(this.websiteEndpoint, subdomain)); + + // when fetching for the second time, the subdomain may be unchanged + if (redirectUrls && Array.isArray(redirectUrls) && redirectUrls.length > 0) { + subdomain = redirectUrls.pop().href.match(/^https?:\/\/(\w+)\.bing\.com/)[1] + } + + const IG = body.match(/IG:"([^"]+)"/)[1]; + const IID = body.match(/data-iid="([^"]+)"/)[1]; + + const [key, token, tokenExpiryInterval]: [number, string, number] = JSON.parse( + body.match(/params_AbusePreventionHelper\s?=\s?([^\]]+])/)[1] + ) + + const requiredFields = { + IG, + IID, + key, + token, + tokenExpiryInterval, + tokenTs: key + } + + // check required fields + for (const [field, value] of Object.entries(requiredFields)) { + if (!value) { + throw new Error(`[E00100] failed to fetch required field: \`${field}\``) + } + } + + return { + ...requiredFields, + // PENDING: reset count when value is large? + count: 0, + subdomain + } + } catch (reason) { + console.log('[E00101] failed to fetch global config', reason); + throw reason + } + } + + private makeRequestBody(isSpellCheck: boolean, text: string, fromLang: TBingLangCodeExtend, toLang?: TBingLangCodeExtend): IBingRequestBody { + const { key, token } = this.gConfig + const body: IBingRequestBody = { + fromLang, + key, + text, + token + } + + if (!isSpellCheck) { + toLang && (body.to = toLang) + + body.tryFetchingGenderDebiasedTranslations = true + } + + return body + } + + private makeRequestURL(isSpellCheck: boolean, useEPT?: boolean) { + const {IG, IID, subdomain} = this.gConfig + return this.replaceSubdomain(isSpellCheck ? this.spellCheckEndpoint : this.translateEndpoint, subdomain) + + '&IG=' + IG + + '&IID=' + (IID + (isSpellCheck || useEPT ? '.' + (++this.gConfig.count) : '')) + + ( + isSpellCheck || !useEPT + ? '' + // PENDING: might no rate limit but some languages are not supported for now + // (See also the `eptLangs` field in src/config.json) + : '&ref=TThis' + + '&edgepdftranslator=1' + ) + } + + private replaceSubdomain(url: string, subdomain?: string): string { + return url.replace('{s}', subdomain ? subdomain + '.' : '') + } + + private async requestTranslate(requestURL: string, requestBody: IBingRequestBody) { + const request = this.client.post(requestURL, { + // agent: this.config.proxyAgents, + // got will set CONTENT_TYPE as `application/x-www-form-urlencoded` + form: requestBody, + responseType: 'json', + }) + + let response: Response; + + let err: RequestError; + let readableErrMsg: string; + + try { + response = await request; + } catch (reason) { + console.log('--- catch ---'); + response = (err = reason).response; + } + + const {body, statusCode, statusMessage} = response + + if (body.ShowCaptcha) { + readableErrMsg = `[E00110] Sorry that bing translator seems to be asking for the captcha, please take care not to request too frequently.` + } else if (body.StatusCode === 401 || statusCode === 401) { + readableErrMsg = `[E00111] Translation limit exceeded. Please try it again later.` + } else if (body.statusCode) { + readableErrMsg = `[E00112] Something went wrong!` + } + + if (readableErrMsg) { + const responseMsg = `[E00113] Response status: ${statusCode} (${statusMessage})\nResponse body : ${JSON.stringify(body)}` + throw new Error(readableErrMsg + '\n' + responseMsg) + } + + if (err) { + console.log('--- 4 ---') + const wrappedErr = new Error(`[E00102] Failed to request translation service`) + wrappedErr.stack += '\n' + err.stack + throw wrappedErr + } + + return body; + } +} + +interface IResponseTranslate { + ShowCaptcha?: boolean; + StatusCode?: number; + correctedText?: string; + detectedLanguage?: { + language: string; + score: number; + }; + statusCode?: number; + translations?: IResponseTranslateItem[]; +} + +interface IResponseTranslateItem { + translations: { + sentLen: { + srcSentLen: number[]; + transSentLen: number[]; + }; + text: string; + to: string; + transliteration: { + script: string + text: string, + }; + } +} diff --git a/src/shared/engines/terra.engine.ts b/src/shared/engines/terra.engine.ts new file mode 100644 index 0000000..2f2fc41 --- /dev/null +++ b/src/shared/engines/terra.engine.ts @@ -0,0 +1,99 @@ +import {Got, got} from "got"; + +import {DEFAULT_USER_AGENT} from "../constants.js"; +import {ITerraConfig, ITerraTranslationResult} from "../entities/terra.config.js"; +import {BaseEngine, IParamTranslateText} from "../entities/translation.engine.js"; +import {TERRA_LANG_MAP, TTerraLangCode, TTerraLangCodeExtend} from "../lang.terra.js"; + +export class TerraEngine implements BaseEngine { + static readonly defaultFrom: TTerraLangCode = null; + static readonly defaultTo: TTerraLangCode = 'en'; + static readonly MAX_RETRY_COUNT = 3; + + private client: Got; + + private readonly userAgent: string; + + constructor(private readonly config: ITerraConfig) { + this.userAgent = DEFAULT_USER_AGENT ?? config.userAgent; + + this.client = got.extend({ + headers: { + 'Accept-Language': 'en-US,en', + 'User-Agent': this.config?.userAgent || this.userAgent, + referer: 'https://translate.terraprint.co' + }, + retry: { + limit: TerraEngine.MAX_RETRY_COUNT, + methods: ['POST'] + }, + timeout: { + request: 1000, + response: 1000, + }, + }); + } + + static getLangCode(lang: TTerraLangCode | string): string { + if (!lang || typeof lang !== 'string') { + return null; + } + + if (lang in TERRA_LANG_MAP) { + return lang; + } + + lang = lang.toLowerCase(); + + if (lang in TERRA_LANG_MAP) { + return lang; + } + + return Object.keys(TERRA_LANG_MAP).find((code: TTerraLangCode | string) => code.toLowerCase() === lang) + } + + /** + * @param text Text for translation + * @param toLangCode Translation language code + * @param fromLangCode Original language code + */ + public async translate(text: string, toLangCode?: TTerraLangCodeExtend, fromLangCode?: TTerraLangCodeExtend): Promise { + + const form = new FormData(); + form.set('q', text); + + form.set('target', toLangCode); + form.set('format', 'text'); + + if (this.config.fromLangCode) { + form.set('source', this.config.fromLangCode); + } else if (fromLangCode) { + form.set('source', fromLangCode); + } else { + throw new Error('[E00213] Not `fromLangCode`'); + } + + if (this.config.apiKey) { + form.set('api_key', this.config.apiKey); + } + + const response = await this.client.post('https://translate.terraprint.co/translate', { + body: form, + maxRedirects: 3, + responseType: 'json', + }); + + const {body, statusCode} = response; + if (statusCode !== 200) { + throw new Error(`[E00213] Response status: ${statusCode}\nResponse body : ${JSON.stringify(body)}`) + } + + return body; + } + + async translateText({from, text, to}: IParamTranslateText) { + const response = await this.translate(text, to, from); + + return response.translatedText + } +} diff --git a/src/shared/engines/translate.engine.ts b/src/shared/engines/translate.engine.ts new file mode 100644 index 0000000..dced9ca --- /dev/null +++ b/src/shared/engines/translate.engine.ts @@ -0,0 +1,67 @@ +import {createHash} from "node:crypto"; +import path from "node:path"; + +import {IConfig} from "../config.js"; +import {CTV_CACHE_ENGINE_FILE} from "../constants.js"; +import {BaseEngine, IParamTranslateText} from "../entities/translation.engine.js"; +import {Helper} from "../helper.js"; +import {BingEngine} from "./bing.engine.js"; +import {TerraEngine} from "./terra.engine.js"; + +let CACHE_TRANSLATE: Record; + +export class TranslateEngine implements BaseEngine { + private readonly cacheEngineFile: string; + + private engine: BaseEngine; + private readonly engineUseCache: boolean; + + constructor(config: IConfig, cacheDir?: string) { + switch (config?.engine) { + case 'bing': + this.engine = new BingEngine(config.bing) + break; + case 'terra': + this.engine = new TerraEngine(config.terra) + break; + default: + throw new Error(`The translation "engine" is not supported.`) + } + + this.engineUseCache = config.engineUseCache && cacheDir; + + this.cacheEngineFile = path.join(cacheDir, CTV_CACHE_ENGINE_FILE); + } + + async translateText({from, text, to}: IParamTranslateText) { + if (!CACHE_TRANSLATE) { + try { + CACHE_TRANSLATE = this.engineUseCache ? await Helper.readJsonFile(this.cacheEngineFile) as Record : {}; + } catch (reason) { + CACHE_TRANSLATE = {}; + + if (reason.code === 'ENOENT') { + await Helper.writeOrCreateJsonFile({}, this.cacheEngineFile); + } else { + throw reason + } + } + } + + const key = createHash('sha256').update([text.toLowerCase(), to || ''].join('_')).digest('hex'); + + if (key in CACHE_TRANSLATE) { + return CACHE_TRANSLATE[key]; + } + + const translate = await this.engine.translateText({from, text, to}); + + CACHE_TRANSLATE[key] = translate; + + if (this.engineUseCache) { + await Helper.writeOrCreateJsonFile(CACHE_TRANSLATE, this.cacheEngineFile); + } + + return translate; + } +} diff --git a/src/shared/entities/bing.config.ts b/src/shared/entities/bing.config.ts new file mode 100644 index 0000000..b088a85 --- /dev/null +++ b/src/shared/entities/bing.config.ts @@ -0,0 +1,79 @@ +import type {Agent as HttpsAgent} from "node:https"; + +import {TBingLangCode, TBingLangCodeExtend} from "../lang.bing.js"; + +export interface IBingProxyAgents { + http?: | false; + http2?: false | unknown; + https?: HttpsAgent | false; +} +export interface IBingConfig { + agent?: string; + /** + * Whether to correct the input text. + */ + correct?: boolean; + proxyAgents?: IBingProxyAgents; + /** + * Whether the translation result contains raw response from Bing API. + */ + raw?: boolean; + userAgent?: string; +} +export interface IBingFetchConfig { + IG: string; + IID: string; + count: number; + key: number; + subdomain?: string; + token: string; + tokenExpiryInterval: number; + tokenTs: number; +} +export interface IBingRequestBody { + fromLang: TBingLangCodeExtend; + key: number; + text: string; + to?: TBingLangCode | TBingLangCodeExtend; + token: string; + tryFetchingGenderDebiasedTranslations?: true +} +export interface IBingTranslationResult { + /** + * The corrected text. This is returned only when the `correct` option is set as `true` + */ + correctedText?: string; + /** + * The detected language + */ + language: { + /** + * The detected language code of original text + */ + from: string; + /** + * The score of language detection + */ + score: number; + /** + * The language code of translated text + */ + to: string; + }; + /** + * The original response from Bing translator + */ + raw?: unknown + /** + * The original text + */ + text: string; + /** + * The translated text + */ + translation: string; + /** + * The user-specified language code + */ + userLang: string; +} diff --git a/src/shared/entities/report.ts b/src/shared/entities/report.ts new file mode 100644 index 0000000..53fae2d --- /dev/null +++ b/src/shared/entities/report.ts @@ -0,0 +1,11 @@ +export interface ILabelDeleteRowReport { + code: string; + labels: string[]; + status: string; +} + +export interface ISyncRowReport { + [landCode: string]: string; + id?: string; + label: string; +} diff --git a/src/shared/entities/terra.config.ts b/src/shared/entities/terra.config.ts new file mode 100644 index 0000000..51bdba3 --- /dev/null +++ b/src/shared/entities/terra.config.ts @@ -0,0 +1,33 @@ +import {TTerraLangCode, TTerraLangCodeExtend} from "../lang.terra.js"; + +export interface ITerraConfig { + /** + * API key + */ + apiKey?: string; + /** + * Original language code + */ + fromLangCode?: TTerraLangCodeExtend; + /** + * Translation language code + */ + toLangCode?: TTerraLangCode; + userAgent?: string; +} + +export interface ITerraTranslationResult { + detectedLanguage?: { + confidence: number, + language: TTerraLangCode + }, + /** + * Description: Represents an error message. + * status code [400, 403, 429, 500] + */ + error?: string; + /** + * Translated text + */ + translatedText?: string; +} diff --git a/src/shared/entities/translate.ts b/src/shared/entities/translate.ts new file mode 100644 index 0000000..0fa750f --- /dev/null +++ b/src/shared/entities/translate.ts @@ -0,0 +1,14 @@ +export interface ITranslation { + /** + * lang code + */ + code: string, + /** + * file path. + */ + file: string, + translate: NonNullable, + translateFlatten: Record +} + +export type TTranslation = Record; diff --git a/src/shared/entities/translation.engine.ts b/src/shared/entities/translation.engine.ts new file mode 100644 index 0000000..8c021bf --- /dev/null +++ b/src/shared/entities/translation.engine.ts @@ -0,0 +1,23 @@ +/** + * The types of translation engines available. + */ +export type TEngineTranslation = 'bing' | 'terra'; + +export interface IParamTranslateText { + /** + * The language code of source text. + */ + from?: string; + /** + * The text to be translated, can't be blank. + */ + text: string; + /** + * The language in which the text should be translated. + */ + to: string; +} + +export interface BaseEngine { + translateText(param: IParamTranslateText): Promise; +} diff --git a/src/shared/helper.ts b/src/shared/helper.ts new file mode 100644 index 0000000..6e5a730 --- /dev/null +++ b/src/shared/helper.ts @@ -0,0 +1,43 @@ +import {readJson, writeJson} from "fs-extra/esm"; +import fs from "node:fs"; +import path from "node:path"; + +import {UTIL} from "./util.js"; + +const readJsonFile = async (pathFile: string): Promise> => { + const data = await readJson(pathFile, {encoding: 'utf8'}); + + return UTIL.isObject(data) ? data : {}; +} + +const writeJsonFile = async (dataJson: NonNullable, pathFile: string, spaces = 2) => { + return writeJson(pathFile, dataJson, { + encoding: 'utf8', + spaces + }); +} + +const writeOrCreateJsonFile = async (dataJson: NonNullable, pathFile: string) => { + const stat = fs.statSync(pathFile, {throwIfNoEntry: false}); + + if (!stat) { + await fs.promises.mkdir(path.dirname(pathFile), {recursive: true}); + + return writeJsonFile(dataJson, pathFile); + } else if (stat.isFile()) { + return writeJsonFile(dataJson, pathFile); + } + + throw new Error(`Path [${pathFile}] already exists and is not a file.`); +} + +const getPathLanguageFile = (lang: string, basePath: string) => { + return path.join(process.cwd(), basePath, `${lang}.json`); +} + +export const Helper = { + getPathLanguageFile, + readJsonFile, + writeJsonFile, + writeOrCreateJsonFile, +} diff --git a/src/shared/label-base.command.ts b/src/shared/label-base.command.ts new file mode 100644 index 0000000..87b222b --- /dev/null +++ b/src/shared/label-base.command.ts @@ -0,0 +1,196 @@ +import {input, select} from "@inquirer/prompts"; +import {Command} from '@oclif/core' +import chalk from "chalk"; +import {readJson} from "fs-extra/esm"; +import fs from "node:fs"; +import path from "node:path"; + +import {CONFIG_DEFAULT, IConfig, LANG_CODE_DEFAULT} from "./config.js"; +import {CTV_CONFIG_FILE_NAME} from "./constants.js"; +import {ITranslation, TTranslation} from "./entities/translate.js"; +import {Helper} from "./helper.js"; +import {UTIL} from "./util.js"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export abstract class LabelBaseCommand extends Command { + protected cliConfig!: IConfig; + + /** + * Retrieves a label for validation or input based on provided parameters. + * + * @async + * @param {Object} options - The options object. + * @param {string} options.label - The label to be validated or entered. + * @param {string} options.labelValidation - The regular expression pattern used to validate the label. + * @param {boolean} clearPromptOnDone - Determines if the prompt should be cleared after input is received (default: true). + * @returns {Promise} The validated label or entered label. + */ + protected async getLabelValidationOrInput({label, labelValidation}: {label?: string, labelValidation?: string}, clearPromptOnDone: boolean = true): Promise { + if (UTIL.isString(label) && UTIL.isString(labelValidation) && (new RegExp(labelValidation, 'gm')).test(label)) { + return label; + } + + return input({ + default: UTIL.isString(label) ? label : null, + message: `Enter label:`, + validate: (v: string) => { + let isValid: boolean; + // eslint-disable-next-line + if (v && UTIL.isString(labelValidation)) { + isValid = (new RegExp(labelValidation, 's')).test(v); + } else { + isValid = UTIL.isString(label) && label.length > 0 && label.length < 500; + } + + if (isValid) { + return true; + } + + return 'Label name is not correct'; + } + }, { + clearPromptOnDone, + }); + } + + protected async getLangCode(languages: string[], fromLangCode?: string, langCodeDefault?: string) { + if (!Array.isArray(languages) || languages.length === 0) { + throw new Error('Language code is not specified in the settings file.') + } + + let language = fromLangCode || langCodeDefault; + + if (language && !languages.includes(language)) { + throw new Error('The specified language code does not exist in the settings.') + } + + if (!language) { + language = await select({ + choices: languages.map( + (lang: string) => ({name: lang, value: lang}) + ), + default: LANG_CODE_DEFAULT, + message: 'Choose a language code:' + }, { + clearPromptOnDone: true, + }) + } + + return language + } + + protected async getLangTranslation(langCode: string): Promise { + const file = Helper.getPathLanguageFile(langCode, this.cliConfig.basePath); + + let translate = {}; + try { + translate = await Helper.readJsonFile(file); + } catch { + this.log(chalk.yellow(`Error reading file [${file}]`)) + } + + return { + code: langCode, + file, + translate, + translateFlatten: UTIL.flattenObject(translate) + }; + } + + protected async getTranslation(text: string, langCode: string, clearPromptOnDone = true) { + if (text) { + return text; + } + + return input({ + message: `Enter translation [${langCode}]:`, + validate: (v: string) => { + const isValid = UTIL.isString(v) && v.length > 0; + + if (isValid && v.length > 1000) { + return 'The maximum number of characters exceeds 1000 characters'; + } + + if (isValid) { + return true; + } + + return 'Label name is not correct'; + } + }, { + clearPromptOnDone, + }) + } + + protected async getTranslationLanguages(): Promise { + const mapLang: TTranslation = {}; + + let langCode: string; + + for (langCode of this.cliConfig.languages) { + mapLang[langCode] = await this.getLangTranslation(langCode); + } + + return mapLang; + } + + protected async makeTranslationEnum(mapLang: ITranslation) { + const {basePathEnum, languages, nameEnum} = {...this.cliConfig}; + + const data = [ + '/* eslint-disable */', + `/**\n * DO NOT EDIT!\n * THIS IS AUTOMATICALLY GENERATED FILE\n * run ${this.config.pjson.name} label:sync \n */`, + '', + `export enum E${nameEnum} {` + ]; + + for (const lang of languages) { + data.push(` ${lang.toUpperCase()} = '${lang}',`); + } + + data.push('}', ''); + + const list = Object.keys(mapLang.translateFlatten) + .sort() + .map((label, k) => { + if (k === 0) { + return `'${label}'`; + } + + return ` | '${label}'`; + }) + + data.push( + `export type T${nameEnum} = ${list.join('\n')};`, + '', + `export type T${nameEnum}OrString = T${nameEnum} | string;`, + `export type T${nameEnum}OrNever = T${nameEnum} | never;`, + '' + ); + + const stat = fs.statSync(basePathEnum, {throwIfNoEntry: false}); + + if (!stat) { + await fs.promises.mkdir(path.dirname(basePathEnum), {recursive: true}); + } else if (!stat.isFile()) { + this.error(`Error path [basePathEnum]: ${basePathEnum}`); + } + + await fs.promises.writeFile(basePathEnum, data.join('\n'),{ flag: "w" }); + } + + /** + * Reads the CLI configuration file `(.ctv.config.json)` and merges it with the default configuration. + * + * @returns {Promise} A promise that resolves to the merged configuration. + */ + protected async readCliConfig(): Promise { + if (!this.cliConfig) { + const conf = await readJson(path.join(process.cwd(), CTV_CONFIG_FILE_NAME)); + + this.cliConfig = UTIL.mergeDeep(CONFIG_DEFAULT, conf) as IConfig; + } + + return this.cliConfig + } +} diff --git a/src/shared/lang.bing.ts b/src/shared/lang.bing.ts new file mode 100644 index 0000000..ecc7776 --- /dev/null +++ b/src/shared/lang.bing.ts @@ -0,0 +1,145 @@ +/* eslint-disable */ +/** + * DO NOT EDIT! + * THIS IS AUTOMATICALLY GENERATED FILE + */ + +export const BING_LANG_MAP = { + "af": "Afrikaans", + "sq": "Albanian", + "am": "Amharic", + "ar": "Arabic", + "hy": "Armenian", + "as": "Assamese", + "az": "Azerbaijani", + "bn": "Bangla", + "ba": "Bashkir", + "eu": "Basque", + "bho": "Bhojpuri", + "brx": "Bodo", + "bs": "Bosnian", + "bg": "Bulgarian", + "yue": "Cantonese (Traditional)", + "ca": "Catalan", + "lzh": "Chinese (Literary)", + "zh-Hans": "Chinese Simplified", + "zh-Hant": "Chinese Traditional", + "hr": "Croatian", + "cs": "Czech", + "da": "Danish", + "prs": "Dari", + "dv": "Divehi", + "doi": "Dogri", + "nl": "Dutch", + "en": "English", + "et": "Estonian", + "fo": "Faroese", + "fj": "Fijian", + "fil": "Filipino", + "fi": "Finnish", + "fr": "French", + "fr-CA": "French (Canada)", + "gl": "Galician", + "lug": "Ganda", + "ka": "Georgian", + "de": "German", + "el": "Greek", + "gu": "Gujarati", + "ht": "Haitian Creole", + "ha": "Hausa", + "he": "Hebrew", + "hi": "Hindi", + "mww": "Hmong Daw", + "hu": "Hungarian", + "is": "Icelandic", + "ig": "Igbo", + "id": "Indonesian", + "ikt": "Inuinnaqtun", + "iu": "Inuktitut", + "iu-Latn": "Inuktitut (Latin)", + "ga": "Irish", + "it": "Italian", + "ja": "Japanese", + "kn": "Kannada", + "ks": "Kashmiri", + "kk": "Kazakh", + "km": "Khmer", + "rw": "Kinyarwanda", + "tlh-Latn": "Klingon (Latin)", + "gom": "Konkani", + "ko": "Korean", + "ku": "Kurdish (Central)", + "kmr": "Kurdish (Northern)", + "ky": "Kyrgyz", + "lo": "Lao", + "lv": "Latvian", + "ln": "Lingala", + "lt": "Lithuanian", + "dsb": "Lower Sorbian", + "mk": "Macedonian", + "mai": "Maithili", + "mg": "Malagasy", + "ms": "Malay", + "ml": "Malayalam", + "mt": "Maltese", + "mr": "Marathi", + "mn-Cyrl": "Mongolian (Cyrillic)", + "mn-Mong": "Mongolian (Traditional)", + "my": "Myanmar (Burmese)", + "mi": "Māori", + "ne": "Nepali", + "nb": "Norwegian", + "nya": "Nyanja", + "or": "Odia", + "ps": "Pashto", + "fa": "Persian", + "pl": "Polish", + "pt": "Portuguese (Brazil)", + "pt-PT": "Portuguese (Portugal)", + "pa": "Punjabi", + "otq": "Querétaro Otomi", + "ro": "Romanian", + "run": "Rundi", + "ru": "Russian", + "sm": "Samoan", + "sr-Cyrl": "Serbian (Cyrillic)", + "sr-Latn": "Serbian (Latin)", + "st": "Sesotho", + "nso": "Sesotho sa Leboa", + "tn": "Setswana", + "sn": "Shona", + "sd": "Sindhi", + "si": "Sinhala", + "sk": "Slovak", + "sl": "Slovenian", + "so": "Somali", + "es": "Spanish", + "sw": "Swahili", + "sv": "Swedish", + "ty": "Tahitian", + "ta": "Tamil", + "tt": "Tatar", + "te": "Telugu", + "th": "Thai", + "bo": "Tibetan", + "ti": "Tigrinya", + "to": "Tongan", + "tr": "Turkish", + "tk": "Turkmen", + "uk": "Ukrainian", + "hsb": "Upper Sorbian", + "ur": "Urdu", + "ug": "Uyghur", + "uz": "Uzbek (Latin)", + "vi": "Vietnamese", + "cy": "Welsh", + "xh": "Xhosa", + "yo": "Yoruba", + "yua": "Yucatec Maya", + "zu": "Zulu" +} + + +export type TBingLangCode = keyof typeof BING_LANG_MAP; +export type TBingLangCodeExtend = keyof typeof BING_LANG_MAP | 'auto-detect' | string; +export type TBingLangCodeName = typeof BING_LANG_MAP[TBingLangCode]; diff --git a/src/shared/lang.terra.ts b/src/shared/lang.terra.ts new file mode 100644 index 0000000..f464a5f --- /dev/null +++ b/src/shared/lang.terra.ts @@ -0,0 +1,41 @@ +/* eslint-disable */ +/** + * DO NOT EDIT! + * THIS IS AUTOMATICALLY GENERATED FILE + */ + +export const TERRA_LANG_MAP = { + "en": "English", + "ar": "Arabic", + "az": "Azerbaijani", + "zh": "Chinese", + "cs": "Czech", + "nl": "Dutch", + "eo": "Esperanto", + "fi": "Finnish", + "fr": "French", + "de": "German", + "el": "Greek", + "hi": "Hindi", + "hu": "Hungarian", + "id": "Indonesian", + "ga": "Irish", + "it": "Italian", + "ja": "Japanese", + "ko": "Korean", + "fa": "Persian", + "pl": "Polish", + "pt": "Portuguese", + "ru": "Russian", + "sk": "Slovak", + "es": "Spanish", + "sv": "Swedish", + "tr": "Turkish", + "uk": "Ukranian", + "vi": "Vietnamese" +} + + +export type TTerraLangCode = keyof typeof TERRA_LANG_MAP; +export type TTerraLangCodeExtend = keyof typeof TERRA_LANG_MAP | 'auto' | string; +export type TTerraLangCodeName = typeof TERRA_LANG_MAP[TTerraLangCode]; diff --git a/src/shared/util.ts b/src/shared/util.ts new file mode 100644 index 0000000..d4975f0 --- /dev/null +++ b/src/shared/util.ts @@ -0,0 +1,191 @@ +const isObject = (val: unknown): boolean => val && typeof val === "object" && !Array.isArray(val); +const isString = (val: unknown): boolean => { + return typeof val === "string" +}; + +/** + * Sorts an object, including nested objects and arrays, by keys. + * + * @param {unknown} value - The value to be sorted. + * @returns {unknown} - The sorted value. + */ +const sortObjByKey = (value: NonNullable): NonNullable => { + return typeof value === "object" + ? Array.isArray(value) + ? value.map((element) => sortObjByKey(element)) + : Object.keys(value) + .sort() + .reduce((o, key) => { + const v = value[key]; + o[key] = sortObjByKey(v); + return o; + }, {}) + : value; +} + +/** + * Set the value of a nested property in an object. + * + * @param {object} obj - The object in which to set the nested property. + * @param {string} keyLabel - The label representing the nested property. + * @param {string} value - The value to set for the nested property. + * @returns {void} + */ +const setNestedValue = (obj: NonNullable, keyLabel: string, value: string | string[]): void => { + let i; + const keyLabelPath = keyLabel.split('.'); + for (i = 0; i < keyLabelPath.length - 1; i++) { + if (!obj[keyLabelPath[i]]) { + obj[keyLabelPath[i]] = {}; + } + + obj = obj[keyLabelPath[i]]; + } + + obj[keyLabelPath[i]] = value; +} + +/** + * Replaces the value of a nested property in an object. + * + * @param {object} obj - The object to modify. + * @param {string} path - The path to the property, separated by dots. + * @param {string} translation - The new value to set. + * @returns {object} - A new object with the modified value. + * @throws {TypeError} - If the specified property is not a string. + */ +const replacePropertyPath = (obj: NonNullable, path: string, translation: string): NonNullable => { + const _obj: NonNullable = JSON.parse(JSON.stringify(obj)); + const keys = path.split('.'); + + keys.reduce((acc, key, index) => { + if (index === keys.length - 1) { + if (typeof acc[key] === "string") { + acc[key] = translation; + } else { + throw new TypeError(`Label [${path}] is not string.`); + } + } + + return acc[key]; + }, _obj); + + return _obj; +} + +/** + * Retrieves a nested value from an object based on a given path. + * + * @param {object} obj - The object to retrieve the value from. + * @param {string} path - The dot-separated path to the desired value. + * @returns {*} - The value found at the specified path, or undefined if not found. + */ +const getNestedValue = (obj: NonNullable, path: string): unknown => { + let i: number; + const kePath = path.split('.'); + for (i = 0; i < kePath.length - 1; i++) { + if (!obj[kePath[i]]) { + obj[kePath[i]] = {}; + } + + obj = obj[kePath[i]]; + } + + return obj[kePath[i]]; +} + +/** + * Flattens a nested object into a single-level object with dot-separated keys. + * + * @param data - The object to be flattened. + * @param parent - Optional parent key used for recursively building flattened keys. + * @returns The flattened object with dot-separated keys. + * @example { + * 'april': 'April', + * 'date.month.april': 'April', + * 'date.month.august': 'August', + * ..., + * } + */ +const flattenObject = (data: NonNullable, parent?: string): Record => { + return Object.entries(data).reduce((acc, [key, val]) => { + if (key.includes(".")) { + console.warn(`*** !WARNING! key "${key}" includes dots. This is unsupported!`); + key = key.replaceAll('.', '_'); + } + + const newKey = parent ? `${parent}.${key}` : key; + if (typeof val === 'string' || Array.isArray(val)) { + acc[newKey] = val; + } else { + acc = {...acc, ...flattenObject(val, newKey)}; + } + + return acc; + }, {}); +} + +/** + * Deletes a property from an object based on the given path. + * This function mutates the original object, removing the specified property. + * + * @param {NonNullable} obj - The object from which the property needs to be deleted. + * @param {string} path - The path of the property to be deleted, using dot notation. + * For example, "prop1.prop2" would delete 'prop2' from 'prop1'. + * @returns {NonNullable} - The mutated object with the property deleted. + */ +const deletePropertyPath = (obj: NonNullable, path: string): NonNullable => { + const _obj = JSON.parse(JSON.stringify(obj)); + const keys = path.split('.'); + + keys.reduce((acc, key, index) => { + if (index === keys.length - 1) { + delete acc[key]; + return true; + } + + return acc[key]; + }, _obj); + + return _obj; +} + +/** + * Merges multiple objects deeply into a single object. + * + * @param {...NonNullable} sources - The objects to merge. + * @returns {NonNullable} - The merged object. + */ +const mergeDeep = (...sources: NonNullable[]): NonNullable => { + const target = {}; + if (sources.length === 0) { + return target; + } + + while (sources.length > 0) { + const source = sources.shift(); + if (isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + target[key] = mergeDeep(target[key], source[key]); + } else { + Object.assign(target, {[key]: source[key]}); + } + } + } + } + + return target; +} + +export const UTIL = { + deletePropertyPath, + flattenObject, + getNestedValue, + isObject, + isString, + mergeDeep, + replacePropertyPath, + setNestedValue, + sortObjByKey, +} diff --git a/test/commands/cache.test.ts b/test/commands/cache.test.ts new file mode 100644 index 0000000..ecb988a --- /dev/null +++ b/test/commands/cache.test.ts @@ -0,0 +1,10 @@ +import {expect, test} from '@oclif/test' + +describe('cache', () => { + test + .stdout() + .command(['cache']) + .it('runs cache', ctx => { + expect(ctx.expectation).to.contain('cache') + }) +}) diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts new file mode 100644 index 0000000..b0b7a47 --- /dev/null +++ b/test/commands/init.test.ts @@ -0,0 +1,51 @@ +import {expect, test} from '@oclif/test' +import {readJson, remove} from "fs-extra/esm"; +import path from "node:path"; + +import {CTV_CONFIG_FILE_NAME} from "../../src/shared/constants.js"; + +describe('init', () => { + const configFile = path.resolve(CTV_CONFIG_FILE_NAME); + + test + .stdout() + .do(async () => { + await remove(configFile) + }) + .command(['init']) + .it('runs init', async (ctx) => { + + expect(ctx.stdout).to.contain('Successfully') + + const data = await readJson(configFile) + + expect(data).to.have.property('basePath') + .to.equal('dist/i18n'); + + expect(data).to.have.property('basePathEnum') + .to.equal('dist/i18n/language.ts'); + + expect(data).to.have.property('engine') + .to.equal('bing'); + + expect(data).to.have.property('nameEnum') + .to.equal('LanguageLabel'); + + expect(data).to.have.property('languages') + .to.be.an('array') + .that.includes("en"); + + expect(data).to.have.property('bing'); + }) + + test + .stdout() + .do(async () => { + await remove(configFile) + }) + .command(['init', '--force']) + .it('runs init --force', async (ctx) => { + + expect(ctx.stdout).to.contain('Successfully') + }) +}) diff --git a/test/commands/label/add.test.ts b/test/commands/label/add.test.ts new file mode 100644 index 0000000..5bb4e82 --- /dev/null +++ b/test/commands/label/add.test.ts @@ -0,0 +1,10 @@ +import {expect, test} from '@oclif/test' + +describe('label:add', () => { + test + .stdout() + .command(['label:add', 'hello.world', '-fen', '-t="Hello World!"']) + .it('runs label:add hello.world', ctx => { + expect(ctx.expectation).to.contain('label:add hello.world') + }) +}) diff --git a/test/commands/label/delete.test.ts b/test/commands/label/delete.test.ts new file mode 100644 index 0000000..d584413 --- /dev/null +++ b/test/commands/label/delete.test.ts @@ -0,0 +1,10 @@ +import {expect, test} from '@oclif/test' + +describe('label:delete', () => { + test + .stdout() + .command(['label:delete', 'hello.world']) + .it('runs label:delete', ctx => { + expect(ctx.expectation).to.contain('label:delete hello.world') + }) +}) diff --git a/test/commands/label/replace.test.ts b/test/commands/label/replace.test.ts new file mode 100644 index 0000000..f3e63ed --- /dev/null +++ b/test/commands/label/replace.test.ts @@ -0,0 +1,10 @@ +import {expect, test} from '@oclif/test' + +describe('label:replace', () => { + test + .stdout() + .command(['label:replace', 'hello.world', '-t="Hello world2!"', '-fen']) + .it('runs label:replace hello.world', ctx => { + expect(ctx.expectation).to.contain('label:replace hello.world') + }) +}) diff --git a/test/commands/label/sync.test.ts b/test/commands/label/sync.test.ts new file mode 100644 index 0000000..fc6e348 --- /dev/null +++ b/test/commands/label/sync.test.ts @@ -0,0 +1,11 @@ +import {expect, test} from '@oclif/test' + +describe('label:sync', () => { + test + .stdout() + .command(['label:sync']) + .it('runs label:sync', ctx => { + // expect(ctx.stdout).to.contain('Done!') + expect(ctx.expectation).to.contain('label:sync') + }) +}) diff --git a/test/commands/translate/bing.test.ts b/test/commands/translate/bing.test.ts new file mode 100644 index 0000000..ba11eef --- /dev/null +++ b/test/commands/translate/bing.test.ts @@ -0,0 +1,12 @@ +import {expect, test} from '@oclif/test' + +const inputText = 'Text'; + +describe('translate:bing', () => { + test + .stdout() + .command([`translate:bing`, inputText, '--to=en']) + .it(`runs translate:bing ${inputText} --to=en`, ctx => { + expect(ctx.stdout).to.contain(inputText) + }) +}) diff --git a/test/init.ts b/test/init.ts new file mode 100644 index 0000000..4e02106 --- /dev/null +++ b/test/init.ts @@ -0,0 +1,5 @@ +import path from "node:path"; + +process.env.TS_NODE_PROJECT = path.resolve('tsconfig.json') +process.env.NODE_ENV = 'development' +process.env.NODE_OPTIONS = '--loader ts-node/esm' diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..1b07d44 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "skipLibCheck": true, + "module": "Node16", + "moduleResolution": "node16" + }, + "paths": { + "@src": [ + "../src/**/*" + ] + }, + "include": ["./**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c854581 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "outDir": "dist", + "declaration": true, + "module": "Node16", + "moduleResolution": "Node16", + "target": "ES2022", + "rootDir": "src", + "strict": false, + "skipLibCheck": true, + "strictNullChecks": false, + "esModuleInterop": true, + "removeComments": true, + "typeRoots": [ + "./node_modules/@types", + "types" + ] + }, + "include": ["./src/**/*"], + "ts-node": { + "esm": true + } +} diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..e69de29