├── .env.example ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── add_issues_to_kanban.yml │ └── run_ci.yml ├── .gitignore ├── .gitlab-ci.yml ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── examples ├── commonjs │ ├── index.js │ └── package.json ├── esmodule │ ├── index.js │ └── package.json ├── typescript │ ├── .gitignore │ ├── index.ts │ ├── package.json │ └── tsconfig.json └── website-translation │ ├── MultipleLanguagesIterator.js │ ├── index.html │ └── package.json ├── jest.config.js ├── jest.setup.js ├── package-lock.json ├── package.json ├── src ├── client.ts ├── deeplClient.ts ├── documentMinifier.ts ├── errors.ts ├── fsHelper.ts ├── glossaryEntries.ts ├── index.ts ├── parsing.ts ├── translator.ts ├── types.ts └── utils.ts ├── tests ├── client.test.ts ├── core.ts ├── documentMinification │ ├── helperMethods.test.ts │ ├── lifecycle.test.ts │ ├── minifyAndDeminify.test.ts │ └── testHelpers.ts ├── fsHelper │ ├── withFsMocks.test.ts │ └── withoutFsMocks.test.ts ├── general.test.ts ├── glossary.test.ts ├── multilingualGlossary.test.ts ├── rephraseText.test.ts ├── resources │ ├── example_document_template.docx │ ├── example_presentation_template.pptx │ └── example_zip_template.zip ├── temp │ └── media │ │ └── parent │ │ └── image.png ├── translateDocument.test.ts └── translateText.test.ts ├── tsconfig-eslint.json ├── tsconfig.json └── upgrading_to_multilingual_glossaries.md /.env.example: -------------------------------------------------------------------------------- 1 | # Default 2 | DEEPL_AUTH_KEY= # Add your API key here 3 | 4 | # Local 5 | # Below is the config for running the mock server in docker (https://github.com/DeepLcom/deepl-mock/) 6 | # DEEPL_MOCK_SERVER_PORT=3000 7 | # DEEPL_AUTH_KEY=PLACEHOLDER 8 | # DEEPL_SERVER_URL=http://localhost:3000 9 | 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | parserOptions: { 5 | tsconfigRootDir: __dirname, 6 | project: ['./tsconfig-eslint.json'], 7 | }, 8 | plugins: ['@typescript-eslint', 'import'], 9 | extends: [ 10 | 'eslint:recommended', 11 | 'airbnb-typescript', 12 | 'plugin:@typescript-eslint/recommended', 13 | 'plugin:eslint-comments/recommended', 14 | 'plugin:promise/recommended', 15 | 'prettier', 16 | ], 17 | env: { 18 | node: true, 19 | }, 20 | ignorePatterns: ['node_modules/**', 'dist/**', 'examples/**', '**/*.js'], 21 | rules: { 22 | eqeqeq: 'error', 23 | '@typescript-eslint/lines-between-class-members': 'off', 24 | 'react/jsx-filename-extension': 'off', 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/workflows/add_issues_to_kanban.yml: -------------------------------------------------------------------------------- 1 | name: Add bugs to bugs project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | 8 | jobs: 9 | add-to-project: 10 | name: Add issue to project 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/add-to-project@v0.5.0 14 | with: 15 | project-url: https://github.com/orgs/DeepLcom/projects/1 16 | github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} 17 | -------------------------------------------------------------------------------- /.github/workflows/run_ci.yml: -------------------------------------------------------------------------------- 1 | name: NodeJS 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: '30 0 * * *' 10 | 11 | env: 12 | SECRET_DETECTION_JSON_REPORT_FILE: "gitleaks.json" 13 | 14 | jobs: 15 | eslint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - name: Install dependencies 21 | run: npm install 22 | - name: Run eslint check 23 | run: npm run lint 24 | 25 | audit: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Install dependencies 31 | run: npm install 32 | - name: Run npm audit 33 | run: | 34 | npm audit --production 35 | # Suppressing NPM audit failures on dev dependencies due to CVE-2022-25883 36 | # in semver <7.5.2, used by test dependencies. 37 | # See: https://github.com/npm/node-semver/pull/564 38 | # Currently npm audit fix cannot resolve this issue. 39 | npm audit || echo "Suppressing npm audit failures for dev dependencies" 40 | 41 | format: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Checkout 45 | uses: actions/checkout@v4 46 | - name: Install dependencies 47 | run: npm install 48 | - name: Run format check 49 | run: npm run format 50 | 51 | secret_detection: 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: Checkout 55 | uses: actions/checkout@v4 56 | with: 57 | fetch-depth: 0 58 | - name: Install and run secret detection 59 | run: | 60 | wget https://github.com/gitleaks/gitleaks/releases/download/v8.18.4/gitleaks_8.18.4_linux_x64.tar.gz 61 | tar -xzf gitleaks_8.18.4_linux_x64.tar.gz 62 | EXITCODE=0 63 | ./gitleaks detect -r ${SECRET_DETECTION_JSON_REPORT_FILE} --source . --log-opts="--all --full-history" || EXITCODE=$? 64 | if [[ $EXITCODE -ne 0 ]]; then 65 | exit $EXITCODE 66 | fi 67 | - name: Upload secret detection artifact 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: secret-detection-results 71 | path: gitleaks.json 72 | 73 | build: 74 | runs-on: ubuntu-latest 75 | steps: 76 | - name: Checkout 77 | uses: actions/checkout@v4 78 | - name: Install dependencies 79 | run: npm install 80 | - name: Build 81 | run: | 82 | npm run clean 83 | npm run build 84 | - name: Upload artifacts 85 | uses: actions/upload-artifact@v4 86 | with: 87 | name: build-artifacts 88 | path: dist 89 | 90 | 91 | # Test and `npm publish` stage are disabled for now. Code needs to be tested 92 | 93 | ####################################################### 94 | # test: 95 | # runs-on: ${{ matrix.config.docker-image }} 96 | # strategy: 97 | # matrix: 98 | # config: 99 | # - docker-image: 'node:18-alpine' 100 | # - docker-image: 'node:12-alpine' 101 | # use-mock-server: use-mock-server 102 | # - docker-image: 'node:14-alpine' 103 | # use-mock-server: use-mock-server 104 | # - docker-image: 'node:16-alpine' 105 | # use-mock-server: use-mock-server 106 | # - docker-image: 'node:17-alpine' 107 | # use-mock-server: use-mock-server 108 | # - docker-image: 'node:18-alpine' 109 | # use-mock-server: use-mock-server 110 | # steps: 111 | # - name: Checkout 112 | # uses: actions/checkout@v4 113 | # - name: Run tests 114 | # run: | 115 | # if [[ ! -z "${{ matrix.config.use-mock-server }}" ]]; then 116 | # echo "Using mock server" 117 | # export DEEPL_SERVER_URL=http://deepl-mock:3000 118 | # export DEEPL_MOCK_SERVER_PORT=3000 119 | # export DEEPL_PROXY_URL=http://deepl-mock:3001 120 | # export DEEPL_MOCK_PROXY_SERVER_PORT=3001 121 | # fi 122 | # npm install 123 | # npm run test:coverage 124 | # - name: Upload test results 125 | # uses: actions/upload-artifact@v4 126 | # with: 127 | # name: test-results 128 | # path: coverage/clover.xml 129 | 130 | # examples: 131 | # runs-on: ${{ matrix.docker-image }} 132 | # strategy: 133 | # matrix: 134 | # docker-image: 135 | # - 'node:12-alpine' 136 | # - 'node:14-alpine' 137 | # - 'node:16-alpine' 138 | # - 'node:17-alpine' 139 | # - 'node:18-alpine' 140 | # steps: 141 | # - name: Checkout 142 | # uses: actions/checkout@v4 143 | # - name: Install dependencies 144 | # run: npm install --production 145 | # - name: Run examples 146 | # run: | 147 | # export DEEPL_AUTH_KEY=mock-auth-key 148 | # export DEEPL_SERVER_URL=http://deepl-mock:3000 149 | # export DEEPL_MOCK_SERVER_PORT=3000 150 | # export DEEPL_PROXY_URL=http://deepl-mock:3001 151 | # export DEEPL_MOCK_PROXY_SERVER_PORT=3001 152 | # cd $CI_PROJECT_DIR/examples/commonjs 153 | # npm install 154 | # node index.js 155 | # cd $CI_PROJECT_DIR/examples/esmodule 156 | # npm install 157 | # node index.js 158 | # cd $CI_PROJECT_DIR/examples/typescript 159 | # npm install 160 | # npm run build 161 | # node index.js 162 | 163 | # publish_to_npm: 164 | # runs-on: ubuntu-latest 165 | # needs: build 166 | # if: startsWith(github.ref, 'refs/tags/v') 167 | # steps: 168 | # - name: Checkout 169 | # uses: actions/checkout@v4 170 | # - name: Publish to NPM 171 | # env: 172 | # NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 173 | # run: npm publish 174 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | dist/ 3 | node_modules/ 4 | coverage/ 5 | junit.xml 6 | examples/**/package-lock.json 7 | examples/**/.env 8 | .DS_Store 9 | .env 10 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | # Note: This GitLab CI configuration is used for internal testing, users can ignore it. 2 | include: 3 | - project: '${CI_PROJECT_NAMESPACE}/ci-libs-for-client-libraries' 4 | file: 5 | - '/${CI_PROJECT_NAME}/.gitlab-ci.yml' 6 | - project: 'deepl/ops/ci-cd-infrastructure/gitlab-ci-lib' 7 | file: 8 | - '/templates/.secret-detection.yml' 9 | - '/templates/.gitlab-release.yml' 10 | - template: Security/SAST.gitlab-ci.yml 11 | 12 | # Global -------------------------- 13 | 14 | # Use Active LTS (18) 15 | image: node:18-alpine 16 | 17 | workflow: 18 | rules: 19 | - if: $CI_PIPELINE_SOURCE == "merge_request_event" 20 | - if: $CI_COMMIT_TAG 21 | - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' 22 | 23 | cache: 24 | key: 25 | files: 26 | - package.json 27 | - package-lock.lock 28 | paths: 29 | - node_modules/ 30 | 31 | stages: 32 | - check 33 | - build 34 | - test 35 | - publish 36 | 37 | before_script: 38 | - npm install 39 | 40 | variables: 41 | GITLAB_ADVANCED_SAST_ENABLED: 'true' 42 | 43 | # stage: check ---------------------- 44 | 45 | .eslint_base: 46 | stage: check 47 | script: 48 | - npm run lint 49 | 50 | eslint_scheduled: 51 | extends: .eslint_base 52 | rules: 53 | - if: $CI_PIPELINE_SOURCE == "schedule" 54 | retry: 2 55 | 56 | eslint_manual: 57 | extends: .eslint_base 58 | rules: 59 | - if: $CI_PIPELINE_SOURCE != "schedule" 60 | 61 | .audit_base: 62 | stage: check 63 | script: 64 | - npm audit --production 65 | # Suppressing NPM audit failures on dev dependencies due to CVE-2022-25883 66 | # in semver <7.5.2, used by test dependencies. 67 | # See: https://github.com/npm/node-semver/pull/564 68 | # Currently npm audit fix cannot resolve this issue. 69 | - npm audit || echo "Suppressing npm audit failures for dev dependencies" 70 | 71 | audit_scheduled: 72 | extends: .audit_base 73 | rules: 74 | - if: $CI_PIPELINE_SOURCE == "schedule" 75 | retry: 2 76 | 77 | audit_manual: 78 | extends: .audit_base 79 | rules: 80 | - if: $CI_PIPELINE_SOURCE != "schedule" 81 | 82 | .format_base: 83 | stage: check 84 | script: 85 | - npm run format 86 | 87 | format_scheduled: 88 | extends: .format_base 89 | rules: 90 | - if: $CI_PIPELINE_SOURCE == "schedule" 91 | retry: 2 92 | 93 | format_manual: 94 | extends: .format_base 95 | rules: 96 | - if: $CI_PIPELINE_SOURCE != "schedule" 97 | 98 | secret_detection: 99 | extends: .secret-detection 100 | stage: check 101 | image: !reference [.secret-detection, image] 102 | before_script: 103 | - echo "overriding default before_script..." 104 | rules: 105 | - if: $CI_MERGE_REQUEST_ID 106 | 107 | gitlab-advanced-sast: 108 | stage: check 109 | rules: 110 | - when: always 111 | before_script: 112 | - '' 113 | variables: 114 | SAST_EXCLUDED_PATHS: '$DEFAULT_SAST_EXCLUDED_PATHS' 115 | GIT_STRATEGY: clone 116 | 117 | semgrep-sast: 118 | stage: check 119 | rules: 120 | - when: always 121 | before_script: 122 | - '' 123 | variables: 124 | SAST_EXCLUDED_PATHS: '$DEFAULT_SAST_EXCLUDED_PATHS' 125 | GIT_STRATEGY: clone 126 | 127 | # stage: build ---------------------- 128 | 129 | build: 130 | stage: build 131 | script: 132 | - npm run clean 133 | - npm run build 134 | artifacts: 135 | paths: 136 | - dist 137 | 138 | # stage: test ---------------------- 139 | 140 | .test_base: 141 | stage: test 142 | extends: .test 143 | retry: 1 144 | variables: 145 | KUBERNETES_MEMORY_LIMIT: 8Gi 146 | parallel: 147 | matrix: 148 | - DOCKER_IMAGE: 'node:18-alpine' 149 | - DOCKER_IMAGE: 'node:12-alpine' 150 | USE_MOCK_SERVER: 'use mock server' 151 | - DOCKER_IMAGE: 'node:14-alpine' 152 | USE_MOCK_SERVER: 'use mock server' 153 | - DOCKER_IMAGE: 'node:16-alpine' 154 | USE_MOCK_SERVER: 'use mock server' 155 | - DOCKER_IMAGE: 'node:17-alpine' 156 | USE_MOCK_SERVER: 'use mock server' 157 | - DOCKER_IMAGE: 'node:18-alpine' 158 | USE_MOCK_SERVER: 'use mock server' 159 | image: ${DOCKER_IMAGE} 160 | script: 161 | - > 162 | if [[ ! -z "${USE_MOCK_SERVER}" ]]; then 163 | echo "Using mock server" 164 | export DEEPL_SERVER_URL=http://deepl-mock:3000 165 | export DEEPL_MOCK_SERVER_PORT=3000 166 | export DEEPL_PROXY_URL=http://deepl-mock:3001 167 | export DEEPL_MOCK_PROXY_SERVER_PORT=3001 168 | fi 169 | - npm run test:coverage 170 | artifacts: 171 | reports: 172 | coverage_report: 173 | coverage_format: cobertura 174 | path: coverage/clover.xml 175 | junit: 176 | - junit.xml 177 | when: always 178 | 179 | test_scheduled: 180 | extends: .test_base 181 | rules: 182 | - if: $CI_PIPELINE_SOURCE == "schedule" 183 | retry: 2 184 | 185 | test_manual: 186 | extends: .test_base 187 | rules: 188 | - if: $CI_PIPELINE_SOURCE != "schedule" 189 | 190 | .examples_base: 191 | stage: test 192 | extends: .test 193 | parallel: 194 | matrix: 195 | - DOCKER_IMAGE: 'node:12-alpine' 196 | - DOCKER_IMAGE: 'node:14-alpine' 197 | - DOCKER_IMAGE: 'node:16-alpine' 198 | - DOCKER_IMAGE: 'node:17-alpine' 199 | - DOCKER_IMAGE: 'node:18-alpine' 200 | image: ${DOCKER_IMAGE} 201 | before_script: # Note: replaces global before_script 202 | - npm install --production 203 | script: 204 | - export DEEPL_AUTH_KEY=mock-auth-key 205 | - export DEEPL_SERVER_URL=http://deepl-mock:3000 206 | - export DEEPL_MOCK_SERVER_PORT=3000 207 | - export DEEPL_PROXY_URL=http://deepl-mock:3001 208 | - export DEEPL_MOCK_PROXY_SERVER_PORT=3001 209 | - cd $CI_PROJECT_DIR/examples/commonjs 210 | - npm install 211 | - node index.js 212 | - cd $CI_PROJECT_DIR/examples/esmodule 213 | - npm install 214 | - node index.js 215 | - cd $CI_PROJECT_DIR/examples/typescript 216 | - npm install 217 | - npm run build 218 | - node index.js 219 | 220 | examples_scheduled: 221 | extends: .examples_base 222 | rules: 223 | - if: $CI_PIPELINE_SOURCE == "schedule" 224 | retry: 2 225 | 226 | examples_manual: 227 | extends: .examples_base 228 | rules: 229 | - if: $CI_PIPELINE_SOURCE != "schedule" 230 | 231 | # stage: publish ---------------------- 232 | 233 | publish to NPM: 234 | stage: publish 235 | extends: .publish 236 | dependencies: 237 | - build 238 | rules: 239 | - if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/' 240 | script: 241 | - npm config set //registry.npmjs.org/:_authToken ${NPM_PUBLISH_TOKEN} 242 | - npm publish 243 | 244 | gitlab release: 245 | stage: publish 246 | extends: .create_gitlab_release 247 | rules: 248 | - if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/' 249 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | .github/ 3 | coverage/ 4 | dist/ 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "useTabs": false, 5 | "tabWidth": 4, 6 | "printWidth": 100, 7 | "overrides": [ 8 | { 9 | "files": "*.ts", 10 | "options": { 11 | "parser": "typescript" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | ### Added 9 | 10 | ### Changed 11 | 12 | 13 | ## [1.18.0] - 2025-05-07 14 | ### Added 15 | * Include `x-trace-id` response headers in debug logs 16 | * Added support for the /v3 Glossary APIs in the client library while providing backwards 17 | compatibility for the previous /v2 Glossary endpoints. Please refer to the README for 18 | usage instructions. 19 | ### Fixed 20 | * Upgrade Babel library 21 | 22 | ## [1.17.3] - 2025-03-11 23 | ### Changed 24 | - Upgrade axios lib due to https://github.com/advisories/GHSA-jr5f-v2jv-69x6 25 | 26 | ## [1.17.2] - 2025-03-06 27 | ### Added 28 | - Added option to override translate path 29 | 30 | ## [1.17.1] - 2025-03-04 31 | ### Added 32 | - Update list of supported target languages 33 | ### Changed 34 | - Adjust environment variable handling to avoid side effects in the published library 35 | 36 | ## [1.17.0] - 2025-02-25 37 | ### Added 38 | * Added document minification as a feature before document translation, to 39 | allow translation of large docx or pptx files. For more info check the README. 40 | * Added .env.example file with `dotenv` library for environment variables management 41 | * Improve unit tests 42 | ### Changed 43 | * Upgrade cross-spawn library (see https://github.com/advisories/GHSA-3xgq-45jj-v275) 44 | 45 | ## [1.16.0] - 2025-01-22 46 | ### Added 47 | * Added support for the Write API in the client library, the implementation 48 | can be found in the `DeepLClient` class. Please refer to the README for usage 49 | instructions. 50 | ### Changed 51 | * The main functionality of the library is now also exposed via the `DeepLClient` 52 | class. Please change your code to use this over the `Translator` class whenever 53 | convenient. 54 | 55 | 56 | ## [1.15.0] - 2024-11-15 57 | ### Added 58 | * Added `modelType` option to `translateText()` to use models with higher 59 | translation quality (available for some language pairs), or better latency. 60 | Options are `'quality_optimized'`, `'latency_optimized'`, and `'prefer_quality_optimized'` 61 | * Added the `modelTypeUsed` field to `translateText()` response, that 62 | indicates the translation model used when the `modelType` option is 63 | specified. 64 | 65 | 66 | ## [1.14.0] - 2024-09-17 67 | ### Added 68 | * Added `billedCharacters` field to text translation response. 69 | 70 | 71 | ## [1.13.1] - 2024-08-14 72 | ### Added 73 | * Added supported glossary languages: Danish (`'da'`), Norwegian (bokmål) 74 | (`'nb'`), and Swedish (`'sv'`). The corresponding glossary language code 75 | TypeScript types are extended. 76 | 77 | Note: older library versions also support the new glossary language pairs, 78 | this update only adds new types. 79 | ### Security 80 | * Increase `axios` locked-version due to security 81 | [vulnerability in axios <1.7.3](https://github.com/advisories/GHSA-8hc4-vh64-cxmj) 82 | 83 | 84 | ## [1.13.0] - 2024-04-12 85 | ### Added 86 | * Add possibility to add extra parameters to a translation request (both text and document). 87 | DeepL engineers use this to test features in the API before they are released. Library users 88 | who cannot update their DeepL library dependency could use these extra parameters to access 89 | features in the API that are released in the future. 90 | ### Security 91 | * Increase `follow-redirects` locked-version due to security 92 | [vulnerability in follow-redirects <1.15.5](https://github.com/advisories/GHSA-cxjh-pqwp-8mfp) 93 | 94 | 95 | ## [1.12.0] - 2024-02-27 96 | ### Added 97 | * New language available: Arabic (`'ar'`). Add language code constants and tests. 98 | Arabic is currently supported only for text translation; document translation 99 | support for Arabic is coming soon. 100 | 101 | Note: older library versions also support the new language, this update only adds new code constants. 102 | 103 | 104 | ## [1.11.1] - 2024-01-26 105 | ### Fixed 106 | * Dependencies: Update `follow-redirects` due to security vulnerability 107 | ### Security 108 | * Increase `axios` requirement to `^1.6.4` to avoid 109 | [vulnerability in follow-redirects <1.15.4](https://github.com/advisories/GHSA-jchw-25xp-jwwc) 110 | 111 | 112 | ## [1.11.0] - 2023-11-03 113 | ### Added 114 | * Add optional `context` parameter for text translation, that specifies 115 | additional context to influence translations, that is not translated itself. 116 | ### Changed 117 | * Added notice in Readme that starting in 2024 the library will drop support for 118 | Node versions that are officially end-of-life. 119 | * Keep-Alive is now used by HTTP(S) agent, to reduce latency for subsequent API requests. 120 | ### Fixed 121 | * CI: silence npm audit warnings in non-production dependencies due to 122 | currently-unresolvable [vulnerability in semver <7.5.2](https://github.com/npm/node-semver/pull/564). 123 | * Increase axios dependency to >=1.2.2, due to [bug in axios v1.2.1](https://github.com/axios/axios/issues/5346). 124 | * Added supported glossary languages: Italian (it), Dutch (nl), Polish (pl), 125 | Portuguese (pt), Russian (ru) and Chinese (zh). The corresponding glossary 126 | language code TypeScript types are extended. 127 | 128 | Note: older library versions also support the new glossary language pairs, 129 | this update only adds new types. 130 | * Fixed typo in readme: `createGlossaryWithCsv` not `createGlossaryFromCsv` 131 | * Issue [#36](https://github.com/DeepLcom/deepl-node/issues/36) thanks to 132 | [phenomen](https://github.com/phenomen). 133 | 134 | 135 | ## [1.10.2] - 2023-06-02 136 | ### Fixed 137 | * Fixed erroneous version bump 138 | 139 | ## [1.10.1] - 2023-06-02 140 | ### Fixed 141 | * Limit example typescript version to 5.0 due to Node 12 incompatibility 142 | 143 | ## [1.10.0] - 2023-06-01 144 | ### Fixed 145 | * Changed document translation to poll the server every 5 seconds. This should greatly reduce observed document translation processing time. 146 | * Fix getUsage request to be a HTTP GET request, not POST. 147 | 148 | 149 | ## [1.9.0] - 2023-03-22 150 | ### Added 151 | * Added platform and node version information to the user-agent string that is sent with API calls, along with an opt-out. 152 | * Added method for applications that use this library to identify themselves in API requests they make. 153 | ### Fixed 154 | * Fixed proxy example code in README 155 | 156 | 157 | ## [1.8.0] - 2023-01-26 158 | ### Added 159 | * New languages available: Korean (`'ko'`) and Norwegian (bokmål) (`'nb'`). Add 160 | language code constants and tests. 161 | 162 | Note: older library versions also support the new languages, this update only 163 | adds new code constants. 164 | 165 | 166 | ## [1.7.5] - 2023-01-25 167 | ### Fixed 168 | * Also send options in API requests even if they are default values. 169 | 170 | 171 | ## [1.7.4] - 2023-01-09 172 | ### Fixed 173 | * Omit undefined `supportsFormality` field for source languages. 174 | 175 | 176 | ## [1.7.3] - 2023-01-04 177 | ### Changed 178 | * CI: suppress `npm audit` warnings for dev dependencies, due to CVE in 179 | `eslint-plugin-import > tsconfig-paths > json5`. 180 | ### Fixed 181 | * Support `axios` v1.2.1, that resolves the issue in v1.2.0. 182 | 183 | 184 | ## [1.7.2] - 2022-11-24 185 | ### Fixed 186 | * Limit `axios` to v1.1.3 or lower due to an issue in v1.2.0. 187 | * This is a temporary workaround until the issue is resolved. 188 | 189 | 190 | ## [1.7.1] - 2022-10-12 191 | ### Fixed 192 | * Prefer `for .. of` loops to `for .. in` loops, to handle cases where array 193 | prototype has been modified. 194 | * Issue [#10](https://github.com/DeepLcom/deepl-node/issues/10) thanks to 195 | [LorenzoJokhan](https://github.com/LorenzoJokhan) 196 | * Node 18 is supported, this is now explicitly documented. 197 | 198 | 199 | ## [1.7.0] - 2022-09-30 200 | ### Added 201 | * Add formality options `'prefer_less'` and `'prefer_more'`. 202 | ### Changed 203 | * Requests resulting in `503 Service Unavailable` errors are now retried. 204 | Attempting to download a document before translation is completed will now 205 | wait and retry (up to 5 times by default), rather than rejecting. 206 | 207 | 208 | ## [1.6.0] - 2022-09-09 209 | ### Added 210 | * New language available: Ukrainian (`'uk'`). Add language code constant and 211 | tests. 212 | 213 | Note: older library versions also support new languages, this update only 214 | adds new code constant. 215 | 216 | 217 | ## [1.5.0] - 2022-08-19 218 | ### Added 219 | * Add proxy support. 220 | 221 | 222 | ## [1.4.0] - 2022-08-09 223 | ### Added 224 | * Add `createGlossaryWithCsv()` allowing glossaries downloaded from website to 225 | be easily uploaded to API. 226 | 227 | 228 | ## [1.3.2] - 2022-08-09 229 | ### Changed 230 | * Update contributing guidelines, we can now accept Pull Requests. 231 | ### Fixed 232 | * Fix GitLab CI config. 233 | * Correct language code case in `getSourceLanguages()` and 234 | `getTargetLanguages()` result. 235 | * Use TypeScript conditional types on `translateText()` to fix TS compiler 236 | errors. 237 | * Issue [#9](https://github.com/DeepLcom/deepl-node/issues/9) thanks to 238 | [Jannis Blossey](https://github.com/jblossey) 239 | 240 | 241 | ## [1.3.1] - 2022-05-18 242 | Replaces version 1.3.0 which was broken due an incorrect package version. 243 | ### Added 244 | * New languages available: Indonesian (`'id'`) and Turkish (`'tr'`). Add 245 | language code constants and tests. 246 | 247 | Note: older library versions also support the new languages, this update only 248 | adds new code constants. 249 | ### Changed 250 | * Change return type of `nonRegionalLanguageCode()` to newly-added type 251 | `NonRegionalLanguageCode`. 252 | 253 | 254 | ## [1.3.0] - 2022-05-18 255 | Due to an incorrect package version, this version was removed. 256 | 257 | 258 | ## [1.2.2] - 2022-04-20 259 | ### Added 260 | * Glossaries are now supported for language pairs: English <-> Japanese and 261 | French <-> German. The corresponding glossary language code TypeScript types 262 | are extended. 263 | 264 | Note: older library versions also support the new glossary language pairs, 265 | this update only adds new types. 266 | 267 | 268 | ## [1.2.1] - 2022-04-14 269 | ### Changed 270 | * Simplify and widen the accepted version range for `node` and `@types/node`. 271 | 272 | 273 | ## [1.2.0] - 2022-04-13 274 | ### Added 275 | * Add `errorMessage` property to `DocumentStatus`, describing the error in case 276 | of document translation failure. 277 | 278 | 279 | ## [1.1.1] - 2022-04-12 280 | ### Fixed 281 | * Fix some tests that intermittently failed. 282 | * Fix `isDocumentTranslationComplete()` to reject if document translation fails. 283 | 284 | 285 | ## [1.1.0] - 2022-03-22 286 | ### Added 287 | - Add support for HTML tag handling. 288 | ### Fixed 289 | - Fix spurious test failures. 290 | 291 | 292 | ## [0.1.2] - 2022-03-10 293 | ### Changed 294 | - Change TypeScript-example to match other examples. 295 | - Improvements to code style and formatting. 296 | - Increase TypeScript compiler target to `es2019`. 297 | 298 | 299 | ## [0.1.1] - 2022-03-04 300 | ### Fixed 301 | - Fix error in package version. 302 | 303 | 304 | ## [0.1.0] - 2022-03-04 305 | Initial release. 306 | 307 | 308 | ## 1.0.0 - 2019-02-04 309 | This version of the package on NPM refers to an earlier unofficial DeepL Node.js 310 | client library, which can be found [here][1.0.0]. The official DeepL Node.js 311 | client library took over this package name. Thanks to 312 | [Tristan De Oliveira](https://github.com/icrotz) for transferring the package 313 | ownership. 314 | 315 | 316 | [Unreleased]: https://github.com/DeepLcom/deepl-node/compare/v1.18.0...HEAD 317 | [1.18.0]: https://github.com/DeepLcom/deepl-node/compare/v1.17.3...v1.18.0 318 | [1.17.3]: https://github.com/DeepLcom/deepl-node/compare/v1.17.2...v1.17.3 319 | [1.17.2]: https://github.com/DeepLcom/deepl-node/compare/v1.17.1...v1.17.2 320 | [1.17.1]: https://github.com/DeepLcom/deepl-node/compare/v1.17.0...v1.17.1 321 | [1.17.0]: https://github.com/DeepLcom/deepl-node/compare/v1.16.0...v1.17.0 322 | [1.16.0]: https://github.com/DeepLcom/deepl-node/compare/v1.15.0...v1.16.0 323 | [1.15.0]: https://github.com/DeepLcom/deepl-node/compare/v1.14.0...v1.15.0 324 | [1.14.0]: https://github.com/DeepLcom/deepl-node/compare/v1.13.1...v1.14.0 325 | [1.13.1]: https://github.com/DeepLcom/deepl-node/compare/v1.13.0...v1.13.1 326 | [1.13.0]: https://github.com/DeepLcom/deepl-node/compare/v1.12.0...v1.13.0 327 | [1.12.0]: https://github.com/DeepLcom/deepl-node/compare/v1.11.1...v1.12.0 328 | [1.11.1]: https://github.com/DeepLcom/deepl-node/compare/v1.11.0...v1.11.1 329 | [1.11.0]: https://github.com/DeepLcom/deepl-node/compare/v1.10.2...v1.11.0 330 | [1.10.2]: https://github.com/DeepLcom/deepl-node/compare/v1.9.0...v1.10.2 331 | [1.10.1]: https://github.com/DeepLcom/deepl-node/compare/v1.9.0...v1.10.1 332 | [1.10.0]: https://github.com/DeepLcom/deepl-node/compare/v1.9.0...v1.10.0 333 | [1.9.0]: https://github.com/DeepLcom/deepl-node/compare/v1.8.0...v1.9.0 334 | [1.8.0]: https://github.com/DeepLcom/deepl-node/compare/v1.7.5...v1.8.0 335 | [1.7.5]: https://github.com/DeepLcom/deepl-node/compare/v1.7.4...v1.7.5 336 | [1.7.4]: https://github.com/DeepLcom/deepl-node/compare/v1.7.3...v1.7.4 337 | [1.7.3]: https://github.com/DeepLcom/deepl-node/compare/v1.7.2...v1.7.3 338 | [1.7.2]: https://github.com/DeepLcom/deepl-node/compare/v1.7.1...v1.7.2 339 | [1.7.1]: https://github.com/DeepLcom/deepl-node/compare/v1.7.0...v1.7.1 340 | [1.7.0]: https://github.com/DeepLcom/deepl-node/compare/v1.6.0...v1.7.0 341 | [1.6.0]: https://github.com/DeepLcom/deepl-node/compare/v1.5.0...v1.6.0 342 | [1.5.0]: https://github.com/DeepLcom/deepl-node/compare/v1.4.0...v1.5.0 343 | [1.4.0]: https://github.com/DeepLcom/deepl-node/compare/v1.3.2...v1.4.0 344 | [1.3.2]: https://github.com/DeepLcom/deepl-node/compare/v1.3.1...v1.3.2 345 | [1.3.1]: https://github.com/DeepLcom/deepl-node/compare/v1.2.2...v1.3.1 346 | [1.3.0]: https://github.com/DeepLcom/deepl-node/releases/tag/v1.3.0 347 | [1.2.2]: https://github.com/DeepLcom/deepl-node/compare/v1.2.1...v1.2.2 348 | [1.2.1]: https://github.com/DeepLcom/deepl-node/compare/v1.2.0...v1.2.1 349 | [1.2.0]: https://github.com/DeepLcom/deepl-node/compare/v1.1.1...v1.2.0 350 | [1.1.1]: https://github.com/DeepLcom/deepl-node/compare/v1.1.0...v1.1.1 351 | [1.1.0]: https://github.com/DeepLcom/deepl-node/compare/v0.1.2...v1.1.0 352 | [0.1.2]: https://github.com/DeepLcom/deepl-node/compare/v0.1.1...v0.1.2 353 | [0.1.1]: https://github.com/DeepLcom/deepl-node/compare/v0.1.0...v0.1.1 354 | [0.1.0]: https://github.com/DeepLcom/deepl-node/releases/tag/v0.1.0 355 | [1.0.0]: https://github.com/icrotz/deepl 356 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | [open-source@deepl.com](mailto:open-source@deepl.com). 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | 135 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing guidelines 2 | 3 | * [Code of Conduct](#code-of-conduct) 4 | * [Issues](#issues) 5 | * [Pull Requests](#pull-requests) 6 | 7 | ## Code of Conduct 8 | 9 | This project has a [Code of Conduct](CODE_OF_CONDUCT.md) to which all 10 | contributors must adhere when participating in the project. Instances of 11 | abusive, harassing, or otherwise unacceptable behavior may be reported by 12 | contacting [open-source@deepl.com](mailto:open-source@deepl.com) and/or a 13 | project maintainer. 14 | 15 | ## Issues 16 | 17 | If you experience problems using the library, or would like to request a new 18 | feature, please open an [issue][issues]. 19 | 20 | Please provide as much context as possible when you open an issue. The 21 | information you provide must be comprehensive enough for us to reproduce the 22 | issue. 23 | 24 | ## Pull Requests 25 | 26 | You are welcome to contribute code and/or documentation in order to fix a bug or 27 | to implement a new feature. Before beginning work, you should create an issue 28 | describing the changes you plan to contribute, to avoid wasting or duplicating 29 | effort. We will then let you know whether we would accept the changes. 30 | 31 | Contributions must be licensed under the same license as the project: 32 | [MIT License](LICENSE). 33 | 34 | Currently automated testing is implemented internally at DeepL, however we plan 35 | to implement publicly visible testing soon. 36 | 37 | [issues]: https://www.github.com/DeepLcom/deepl-node/issues 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 DeepL SE (https://www.deepl.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | To report security concerns or vulnerabilities within deepl-node, please email 2 | us at [security@deepl.com](mailto:security@deepl.com). 3 | 4 | You can send us PGP-encrypted email using the following PGP public key: 5 | 6 | ``` 7 | -----BEGIN PGP PUBLIC KEY BLOCK----- 8 | 9 | mQINBF7WSmABEADzRUp22VY7bVfUWScKLi9o8BRSEL4u3aPn9WOQoRLQH0j3dNNQ 10 | FQlwTPn/Ez6qreEl8mX0aE+eLCEykXqsrU/UaTSTslF+H6UQyuGLXkRm8Lblt93I 11 | OEhL069fC7rm+/zJq72+hishBF8DXqa+WtFd8VfK3i211vRhU/teKeAKT0xiuN/5 12 | EQl1Bn7jR7mmQtNbPBhCsAlaC/tNUQ3Lyj6LYnnco7ums5Q/gCvfs2HM3mXJyvnG 13 | 1MC2IrECPowTt04W3V1uXuMcm766orTG/AmtBIbPmOzao4sfqwRVHGvc8zcr1az9 14 | 0nVyEJXx1eUVRDU1GAQuMjEkGgwvTd+nt6sHpn8C+9hMYJhon9veDSupViBuvNRC 15 | p1PnpSLYYy7tA7DPXMhP3cMXe+Z4bYzgwi3xjOwh6SDyB4OFIxtGyuMrLGfZnd6A 16 | hDH4H9zHPpciD5MxcOaqlKdgABQALvc6MvJ1Guf1ckGTbfHz1brtR1LPMK8rrnNu 17 | kwQzgkogYV6YAnt8LPXMPa2Vgy8TAiby7GPaATPeSWdNHtkuYGhWNVbnb60kEWiJ 18 | /RgHFZYfRT1dEcKoQEcDJ7AV14urEFIAfmhlsT8h7iJYUQMa45BakUubi3aWwcme 19 | ya+5WXvp2xU14VMfrscApA0e1v0VcTNVwlKambs/lwims0/xiSaXJS6gVwARAQAB 20 | tCNEZWVwTCBTZWN1cml0eSA8c2VjdXJpdHlAZGVlcGwuY29tPokCTgQTAQgAOBYh 21 | BGvTAPE3gtThLDZ9+ey96Y7yK41BBQJe1kpgAhsDBQsJCAcCBhUKCQgLAgQWAgMB 22 | Ah4BAheAAAoJEOy96Y7yK41BHVIP/04R08g4N32c47edY6z3sl3DAf+/6UI4Bc4S 23 | Jg5L4JcfrsKaDd55plps8nj31VXrxVBO0NrO6HLC50SXbYVrANyo0occ2mIoU8c2 24 | tNbYCUmJ3QjlUwDjHWlMV2J9FcfZkv7z+2TDY6DF8MKqCMi8j7Pnj0hlY0JytciH 25 | SGES1q8+//8tG9z6b6vvxBFfJI+iNXvcbn6uU1WRvGoBqq2A13fXuwTXiNNphsvu 26 | kHqBHSxnf/EAmcmBX0tm6yaWDdwy+rrcDNwXiqqvK6DFWEE7+/9t2FhlgzvuCOfx 27 | dQVMZL8WH2rr6OPQLDgtGxEUFmD+srmqbVn5NKdY6lQ/BEaraozDkuqJEb0/L/kb 28 | Dv+buz8rmKze0XPlrt1XTQ5ZDQp8AMAaPp1UsizVhasZgxxuUa+g5mMbJr7TSNJN 29 | CIqidnh1MEyIr3IccOAr2F51hn6keKIdVnO4dWrWNMTfk00dw3fPGFhNTniITTF2 30 | s3oJ8cy2NMNkVMP5XL3bulpgkKN+hXa4IHkTfWRv7hfYJ/3i3yTRNRjYGRoVp7eM 31 | iADumKaZy5Szl458txuI+p9DGAEvkSJoF7ptwedSvVZ/FZukS5mwYisRV9shzsXF 32 | 3jpcGZ1B3qS68r9ySqnPEWR6oT8p63fpMNVMjz5r4YEbvU0A62OhUk52drLM6SgC 33 | mdOZcmnHuQINBF7WSmABEADc6L/wSexm4l1GWZSQpJ35ldlu7jjWQGguQeeG2900 34 | aEI3UcftMCWg+apwf4h4Yj2YjzUncYAM6RenGvXgZUYQe3OHb8uqpkSmYHUdB/Uq 35 | I4NPO3e8RMDo9YohPKCpZ7jV70X8F9GOUkUgfp29CjrMOYgSLwkSyWotsQ9KtkEH 36 | Sx/h+gviIERe0dkiN9lCsReNigoWLleH4qBSZGPxqF4tzANJ6D2tnAv+6KUQvho3 37 | CdijBiia4o16p9M0altSqsZCEX1Y5BKmWIh9fvvS2uB7SdzS0gcASzlekMGCjG10 38 | dNji+uSNdHExlbl0kUpEL1TuY2hxPBa6lc1hckI3dGng0jIFlio4s8DG3Utmrj3C 39 | KQFxnjqtO+uaJ8HdNo8ObtEp/v9TpsHWUchBTrBP4XN5KwqkljF8XVBA6ceh8H38 40 | 7/RVWRcWp6h30ROm1DTnAGxJk02fbjpnEO0EvudxKTlnAJXV6z+Tm3yYaR4gQYa3 41 | /zfLZgz0z0MqNUsGephZGPzfUX7Lsz6HGUoo7I1KST6xD2QodJYOhHIEOgsqskk+ 42 | cgeXp45X5JLlCQaBLQoL8ut6CTcop1/6U+JZtrm6DdXTZfq57sqfDI+gkG8WljRY 43 | yhsCL+xWiwDjtt/8kpk+W75EQmwPuctoS85Rm6hEpffewdQtb2XCEWpbta6hE1r1 44 | kQARAQABiQI2BBgBCAAgFiEEa9MA8TeC1OEsNn357L3pjvIrjUEFAl7WSmACGwwA 45 | CgkQ7L3pjvIrjUHFvg/9GnIW9SM/nYJpi1xZVWWGwQ+/kTceD50bv8kyvNaia/9m 46 | HG6n83xHNTRBYnt8NtTqHvW0y20Cp3gUs2WxboDgCIb3+srI2ipwiaDJcq+rVr0f 47 | XkCe5MryioKRbTFQ8OgvKh9GK/tYtqZakn7Q9596ajUjHOQV1+Uw/jywLYRlcbqI 48 | zbxyNVWitxPs3Z7jUDAvhPOIOmhLFc+QxSYrs1W4ZEGnZ3+9utqzlEiMusy9Rq0T 49 | /W/wrG6SckebjhrwWZJmy/hkW6V6LUX4++vCVV5+zwsvgEortCV8bhvLfqQDr/WN 50 | fnmbNZtXJbyhTYbcYReOLeKidxO2lZEemnX6iOt5xCdoMcYU23xDT9+tE7Eh6Nfw 51 | einZemBwfku5vxxPF73pOoQUCRq9tgvUrEq+3BqkqidhnFUOPi0J5726q1PBG65x 52 | 5o+SQyvB3NA3al3mEH65z3V3/g0UHnhGcEMwVOXBkffgdKNhWYw59qhSVQnkiq0U 53 | MG10g/RL7VdiISAFPTDmKWUaEDYosinKqOMHwcaVdJq9ssvPf89et6yP/ZkbLIHs 54 | 2y3oiPonh2RMxi2OedlDz+Jp/A2o3qHmwNvBx/meGB0praGUonFVZTAA1EMS39Bi 55 | NhG/L8giTyzA0mMkTJAPXtUVlRe5rEjORgYJsgRqZxEfpsJC9OkvYS4ayO0eCEs= 56 | =jVHt 57 | -----END PGP PUBLIC KEY BLOCK----- 58 | ``` 59 | -------------------------------------------------------------------------------- /examples/commonjs/index.js: -------------------------------------------------------------------------------- 1 | // Copyright 2022 DeepL SE (https://www.deepl.com) 2 | // Use of this source code is governed by an MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | const deepl = require('deepl-node'); 6 | 7 | const authKey = process.env['DEEPL_AUTH_KEY']; 8 | const serverUrl = process.env['DEEPL_SERVER_URL']; 9 | const translator = new deepl.Translator(authKey, { serverUrl: serverUrl }); 10 | 11 | translator 12 | .getUsage() 13 | .then((usage) => { 14 | console.log(usage); 15 | return translator.translateText('Hello, world!', null, 'fr'); 16 | }) 17 | .then((result) => { 18 | console.log(result.text); // Bonjour, le monde ! 19 | }) 20 | .catch((error) => { 21 | console.error(error); 22 | process.exit(1); 23 | }); 24 | -------------------------------------------------------------------------------- /examples/commonjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deepl-node-example-commonjs", 3 | "description": "Example for deepl-node using CommonJS", 4 | "type": "commonjs", 5 | "dependencies": { 6 | "deepl-node": "file:../.." 7 | }, 8 | "main": "index.js" 9 | } 10 | -------------------------------------------------------------------------------- /examples/esmodule/index.js: -------------------------------------------------------------------------------- 1 | // Copyright 2022 DeepL SE (https://www.deepl.com) 2 | // Use of this source code is governed by an MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | import * as deepl from 'deepl-node'; 6 | 7 | const authKey = process.env['DEEPL_AUTH_KEY']; 8 | const serverUrl = process.env['DEEPL_SERVER_URL']; 9 | const translator = new deepl.Translator(authKey, { serverUrl: serverUrl }); 10 | 11 | (async () => { 12 | try { 13 | console.log(await translator.getUsage()); 14 | 15 | const result = await translator.translateText('Hello, world!', null, 'fr'); 16 | 17 | console.log(result.text); // Bonjour, le monde ! 18 | } catch (error) { 19 | console.log(error); 20 | process.exit(1); 21 | } 22 | })(); 23 | -------------------------------------------------------------------------------- /examples/esmodule/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deepl-node-example-esmodule", 3 | "description": "Example for deepl-node using ES Modules", 4 | "type": "module", 5 | "dependencies": { 6 | "deepl-node": "file:../.." 7 | }, 8 | "main": "index.js" 9 | } 10 | -------------------------------------------------------------------------------- /examples/typescript/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | 3 | -------------------------------------------------------------------------------- /examples/typescript/index.ts: -------------------------------------------------------------------------------- 1 | import * as deepl from 'deepl-node'; 2 | 3 | const authKey = process.env['DEEPL_AUTH_KEY']; 4 | if (authKey === undefined) throw new Error('DEEPL_AUTH_KEY environment variable not defined'); 5 | const serverUrl = process.env['DEEPL_SERVER_URL']; 6 | const translator = new deepl.Translator(authKey, { serverUrl: serverUrl }); 7 | 8 | (async () => { 9 | try { 10 | console.log(await translator.getUsage()); 11 | 12 | const targetLang: deepl.TargetLanguageCode = 'fr'; 13 | 14 | const result: deepl.TextResult = await translator.translateText( 15 | 'Hello, world!', 16 | null, 17 | targetLang, 18 | ); 19 | 20 | console.log(result.text); // Bonjour, le monde ! 21 | } catch (error) { 22 | console.log(error); 23 | process.exit(1); 24 | } 25 | })(); 26 | -------------------------------------------------------------------------------- /examples/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deepl-node-example-typescript", 3 | "description": "Example for deepl-node using TypeScript", 4 | "type": "module", 5 | "dependencies": { 6 | "@types/node": "^18.11.9", 7 | "deepl-node": "file:../.." 8 | }, 9 | "main": "index.ts", 10 | "scripts": { 11 | "build": "tsc -p tsconfig.json" 12 | }, 13 | "devDependencies": { 14 | "typescript": ">=3.0 && <5.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2015", 4 | "moduleResolution": "node", 5 | "target": "es6" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/website-translation/MultipleLanguagesIterator.js: -------------------------------------------------------------------------------- 1 | // Example of translating HTML content into multiple target languages 2 | import * as deepl from 'deepl-node'; 3 | import 'dotenv/config'; 4 | 5 | const authKey = process.env.DEEPL_AUTH_KEY; 6 | if (authKey === undefined) { 7 | console.error( 8 | 'You must specify your DeepL auth key as the environment variable DEEPL_AUTH_KEY', 9 | ); 10 | process.exit(1); 11 | } 12 | const translator = new deepl.Translator(authKey); 13 | // Enter some language codes. 14 | var languageCodes = ['bg', 'de', 'it', 'es']; 15 | 16 | // Applying the translator to each language code. Using Promises to iterate through languages in parallel. Await waits for a response before conducting further logic on output. 17 | let translatePromises = languageCodes.map((code) => 18 | translator 19 | .translateDocument('index.html', 'index_' + code + '.html', 'en', code) 20 | .catch((error) => { 21 | if (error.documentHandle) { 22 | const handle = error.documentHandle; 23 | console.log( 24 | `Document ID: ${handle.documentId}, ` + 25 | `Document key: ${handle.documentKey}` + 26 | `For Language Code: ${code}, error occurred during document translation: ${error}`, 27 | ); 28 | } else { 29 | console.log( 30 | `For Language Code: ${code}, error occurred during document upload: ${error}`, 31 | ); 32 | } 33 | }), 34 | ); 35 | await Promise.all(translatePromises); 36 | -------------------------------------------------------------------------------- /examples/website-translation/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 23 | 24 | 25 |

26 | Hello Good Afternoon! 27 |

28 |

This is an example of how to translate a webpage using the DeepL Node.js library.

29 |

30 | The Node.js script in this example translates the English version of this page into 31 | other languages, and the buttons below lead to those translated pages. 32 |

33 |

34 | To run the Node.js script, run the following in the 35 | website-translation directory. 36 |
37 | 38 | npm install 39 |
40 | export DEEPL_AUTH_KEY=<your auth key> 41 |
42 | npm run main 43 |
44 |

45 |

46 | After the translated pages are generated, you can use the buttons below to view this 47 | page in your preferred language. 48 |

49 |

50 | English 51 | German 52 | Spanish 53 | Bulgarian 54 | Italian 55 |

56 | 57 | 58 | -------------------------------------------------------------------------------- /examples/website-translation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website-translation", 3 | "description": "Example for translating a website into multiple target languages", 4 | "type": "module", 5 | "dependencies": { 6 | "@types/node": "^18.11.9", 7 | "deepl-node": "file:../..", 8 | "dotenv": "^16.4.5" 9 | }, 10 | "main": "MultipleLanguagesIterator.js", 11 | "scripts": { 12 | "main": "node MultipleLanguagesIterator.js" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | preset: 'ts-jest/presets/default-esm', 3 | collectCoverageFrom: ['src/*.ts', '!**/node_modules/**', '!**/tests/**'], 4 | moduleNameMapper: { 5 | 'deepl-node(.*)': '/src$1', 6 | axios: 'axios/dist/node/axios.cjs', 7 | }, 8 | setupFiles: ['/jest.setup.js'], 9 | }; 10 | module.exports = config; 11 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); // Load environment variables from .env 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deepl-node", 3 | "description": "deepl-node is the official DeepL Node.js client library", 4 | "version": "1.18.0", 5 | "author": "DeepL SE (https://www.deepl.com)", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git://github.com/DeepLcom/deepl-node.git" 10 | }, 11 | "bugs": "https://github.com/DeepLcom/deepl-node/issues", 12 | "homepage": "https://www.deepl.com/", 13 | "engines": { 14 | "node": ">=12.0" 15 | }, 16 | "keywords": [ 17 | "deepl", 18 | "translator", 19 | "api" 20 | ], 21 | "dependencies": { 22 | "@types/node": ">=12.0", 23 | "adm-zip": "^0.5.16", 24 | "axios": "^1.7.4", 25 | "form-data": "^3.0.0", 26 | "loglevel": ">=1.6.2", 27 | "uuid": "^8.3.2" 28 | }, 29 | "devDependencies": { 30 | "@types/adm-zip": "^0.5.7", 31 | "@types/jest": "^27.0.3", 32 | "@types/mock-fs": "^4.13.4", 33 | "@types/uuid": "^8.3.4", 34 | "@typescript-eslint/eslint-plugin": "^5.6.0", 35 | "@typescript-eslint/parser": "^5.6.0", 36 | "dotenv": "^16.4.7", 37 | "eslint": "^8.4.1", 38 | "eslint-config-airbnb-typescript": "^16.1.0", 39 | "eslint-config-prettier": "^8.5.0", 40 | "eslint-plugin-eslint-comments": "^3.2.0", 41 | "eslint-plugin-import": "^2.25.4", 42 | "eslint-plugin-promise": "^6.0.0", 43 | "jest": "^27.4.3", 44 | "jest-junit": "^13.0.0", 45 | "mock-fs": "^5.5.0", 46 | "nock": "^13.3.0", 47 | "prettier": "^2.5.1", 48 | "ts-jest": "^27.1.1", 49 | "typescript": "^4.5.3" 50 | }, 51 | "files": [ 52 | "CHANGELOG.md", 53 | "CONTRIBUTING.md", 54 | "dist", 55 | "LICENSE", 56 | "README.md", 57 | "SECURITY.md" 58 | ], 59 | "main": "dist/index.js", 60 | "types": "dist/index.d.ts", 61 | "scripts": { 62 | "build": "tsc -p tsconfig.json", 63 | "clean": "rm -rf dist/*", 64 | "lint": "eslint .", 65 | "lint:fix": "eslint --fix .", 66 | "format": "prettier --check .", 67 | "format:fix": "prettier --write .", 68 | "test": "jest", 69 | "test:coverage": "jest --coverage --reporters=\"default\" --reporters=\"jest-junit\" --maxWorkers=5" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 DeepL SE (https://www.deepl.com) 2 | // Use of this source code is governed by an MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | import { ConnectionError } from './errors'; 6 | import { logDebug, logInfo, timeout } from './utils'; 7 | 8 | import axios, { AxiosError, AxiosRequestConfig } from 'axios'; 9 | import { URLSearchParams } from 'url'; 10 | import FormData from 'form-data'; 11 | import { IncomingMessage } from 'http'; 12 | import { ProxyConfig } from './types'; 13 | import * as https from 'https'; 14 | import * as http from 'http'; 15 | 16 | type HttpMethod = 'GET' | 'DELETE' | 'POST' | 'PUT' | 'PATCH'; 17 | 18 | const axiosInstance = axios.create({ 19 | httpAgent: new http.Agent({ keepAlive: true }), 20 | httpsAgent: new https.Agent({ keepAlive: true }), 21 | }); 22 | 23 | /** 24 | * Options for sending HTTP requests. 25 | * @private 26 | */ 27 | interface SendRequestOptions { 28 | /** 29 | * Fields to include in message body (or params). Values must be either strings, or arrays of 30 | * strings (for repeated parameters). 31 | */ 32 | data?: URLSearchParams; 33 | /** Extra HTTP headers to include in request, in addition to headers defined in constructor. */ 34 | headers?: Record; 35 | /** Buffer containing file data to include. */ 36 | fileBuffer?: Buffer; 37 | /** Filename of file to include. */ 38 | filename?: string; 39 | } 40 | 41 | /** 42 | * Internal class implementing exponential-backoff timer. 43 | * @private 44 | */ 45 | class BackoffTimer { 46 | private backoffInitial = 1.0; 47 | private backoffMax = 120.0; 48 | private backoffJitter = 0.23; 49 | private backoffMultiplier = 1.6; 50 | private numRetries: number; 51 | private backoff: number; 52 | private deadline: number; 53 | 54 | constructor() { 55 | this.numRetries = 0; 56 | this.backoff = this.backoffInitial * 1000.0; 57 | this.deadline = Date.now() + this.backoff; 58 | } 59 | 60 | getNumRetries(): number { 61 | return this.numRetries; 62 | } 63 | 64 | getTimeout(): number { 65 | return this.getTimeUntilDeadline(); 66 | } 67 | 68 | getTimeUntilDeadline(): number { 69 | return Math.max(this.deadline - Date.now(), 0.0); 70 | } 71 | 72 | async sleepUntilDeadline() { 73 | await timeout(this.getTimeUntilDeadline()); 74 | 75 | // Apply multiplier to current backoff time 76 | this.backoff = Math.min(this.backoff * this.backoffMultiplier, this.backoffMax * 1000.0); 77 | 78 | // Get deadline by applying jitter as a proportion of backoff: 79 | // if jitter is 0.1, then multiply backoff by random value in [0.9, 1.1] 80 | this.deadline = 81 | Date.now() + this.backoff * (1 + this.backoffJitter * (2 * Math.random() - 1)); 82 | this.numRetries++; 83 | } 84 | } 85 | 86 | /** 87 | * Internal class implementing HTTP requests. 88 | * @private 89 | */ 90 | export class HttpClient { 91 | private readonly serverUrl: string; 92 | private readonly headers: Record; 93 | private readonly minTimeout: number; 94 | private readonly maxRetries: number; 95 | private readonly proxy?: ProxyConfig; 96 | 97 | constructor( 98 | serverUrl: string, 99 | headers: Record, 100 | maxRetries: number, 101 | minTimeout: number, 102 | proxy?: ProxyConfig, 103 | ) { 104 | this.serverUrl = serverUrl; 105 | this.headers = headers; 106 | this.maxRetries = maxRetries; 107 | this.minTimeout = minTimeout; 108 | this.proxy = proxy; 109 | } 110 | 111 | prepareRequest( 112 | method: HttpMethod, 113 | url: string, 114 | timeoutMs: number, 115 | responseAsStream: boolean, 116 | options: SendRequestOptions, 117 | ): AxiosRequestConfig { 118 | const headers = Object.assign({}, this.headers, options.headers); 119 | 120 | const axiosRequestConfig: AxiosRequestConfig = { 121 | url, 122 | method, 123 | baseURL: this.serverUrl, 124 | headers, 125 | responseType: responseAsStream ? 'stream' : 'text', 126 | timeout: timeoutMs, 127 | validateStatus: null, // do not throw errors for any status codes 128 | }; 129 | 130 | if (options.fileBuffer) { 131 | const form = new FormData(); 132 | form.append('file', options.fileBuffer, { filename: options.filename }); 133 | if (options.data) { 134 | for (const [key, value] of options.data.entries()) { 135 | form.append(key, value); 136 | } 137 | } 138 | axiosRequestConfig.data = form; 139 | if (axiosRequestConfig.headers === undefined) { 140 | axiosRequestConfig.headers = {}; 141 | } 142 | Object.assign(axiosRequestConfig.headers, form.getHeaders()); 143 | } else if (options.data) { 144 | if (method === 'GET') { 145 | axiosRequestConfig.params = options.data; 146 | } else { 147 | axiosRequestConfig.data = options.data; 148 | } 149 | } 150 | axiosRequestConfig.proxy = this.proxy; 151 | return axiosRequestConfig; 152 | } 153 | 154 | /** 155 | * Makes API request retrying if necessary, and returns (as Promise) response. 156 | * @param method HTTP method, for example 'GET' 157 | * @param url Path to endpoint, excluding base server URL. 158 | * @param options Additional options controlling request. 159 | * @param responseAsStream Set to true if the return type is IncomingMessage. 160 | * @return Fulfills with status code and response (as text or stream). 161 | */ 162 | async sendRequestWithBackoff( 163 | method: HttpMethod, 164 | url: string, 165 | options?: SendRequestOptions, 166 | responseAsStream = false, 167 | ): Promise<{ statusCode: number; content: TContent }> { 168 | options = options === undefined ? {} : options; 169 | logInfo(`Request to DeepL API ${method} ${url}`); 170 | logDebug(`Request details: ${options.data}`); 171 | const backoff = new BackoffTimer(); 172 | let response, error; 173 | while (backoff.getNumRetries() <= this.maxRetries) { 174 | const timeoutMs = Math.max(this.minTimeout, backoff.getTimeout()); 175 | const axiosRequestConfig = this.prepareRequest( 176 | method, 177 | url, 178 | timeoutMs, 179 | responseAsStream, 180 | options, 181 | ); 182 | try { 183 | response = await HttpClient.sendAxiosRequest(axiosRequestConfig); 184 | error = undefined; 185 | } catch (e) { 186 | response = undefined; 187 | error = e as ConnectionError; 188 | } 189 | 190 | if ( 191 | !HttpClient.shouldRetry(response?.statusCode, error) || 192 | backoff.getNumRetries() + 1 >= this.maxRetries 193 | ) { 194 | break; 195 | } 196 | 197 | if (error !== undefined) { 198 | logDebug(`Encountered a retryable-error: ${error.message}`); 199 | } 200 | 201 | logInfo( 202 | `Starting retry ${backoff.getNumRetries() + 1} for request ${method}` + 203 | ` ${url} after sleeping for ${backoff.getTimeUntilDeadline()} seconds.`, 204 | ); 205 | await backoff.sleepUntilDeadline(); 206 | } 207 | 208 | if (response !== undefined) { 209 | const { statusCode, content } = response; 210 | logInfo(`DeepL API response ${method} ${url} ${statusCode}`); 211 | if (!responseAsStream) { 212 | logDebug('Response details:', { content: content }); 213 | } 214 | return response; 215 | } else { 216 | throw error as Error; 217 | } 218 | } 219 | 220 | /** 221 | * Performs given HTTP request and returns status code and response content (text or stream). 222 | * @param axiosRequestConfig 223 | * @private 224 | */ 225 | private static async sendAxiosRequest( 226 | axiosRequestConfig: AxiosRequestConfig, 227 | ): Promise<{ statusCode: number; content: TContent }> { 228 | try { 229 | const response = await axiosInstance.request(axiosRequestConfig); 230 | if (response.headers !== undefined) { 231 | logDebug('Trace details:', { 232 | xTraceId: response.headers['x-trace-id'], 233 | }); 234 | } 235 | 236 | if (axiosRequestConfig.responseType === 'text') { 237 | // Workaround for axios-bug: https://github.com/axios/axios/issues/907 238 | if (typeof response.data === 'object') { 239 | response.data = JSON.stringify(response.data); 240 | } 241 | } 242 | return { 243 | statusCode: response.status, 244 | content: response.data, 245 | }; 246 | } catch (axios_error_raw) { 247 | const axiosError = axios_error_raw as AxiosError; 248 | const message: string = axiosError.message || ''; 249 | 250 | const error = new ConnectionError(`Connection failure: ${message}`); 251 | error.error = axiosError; 252 | if (axiosError.code === 'ETIMEDOUT') { 253 | error.shouldRetry = true; 254 | } else if (axiosError.code === 'ECONNABORTED') { 255 | error.shouldRetry = true; 256 | } else { 257 | logDebug('Unrecognized axios error', axiosError); 258 | error.shouldRetry = false; 259 | } 260 | throw error; 261 | } 262 | } 263 | 264 | private static shouldRetry(statusCode?: number, error?: ConnectionError): boolean { 265 | if (statusCode === undefined) { 266 | return (error as ConnectionError).shouldRetry; 267 | } 268 | 269 | // Retry on Too-Many-Requests error and internal errors 270 | return statusCode === 429 || statusCode >= 500; 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 DeepL SE (https://www.deepl.com) 2 | // Use of this source code is governed by an MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | import { DocumentHandle } from './index'; 6 | 7 | export class DeepLError extends Error { 8 | public error?: Error; 9 | 10 | constructor(message: string, error?: Error) { 11 | super(message); 12 | this.message = message; 13 | this.error = error; 14 | } 15 | } 16 | 17 | export class AuthorizationError extends DeepLError {} 18 | 19 | export class QuotaExceededError extends DeepLError {} 20 | 21 | export class TooManyRequestsError extends DeepLError {} 22 | 23 | export class ConnectionError extends DeepLError { 24 | shouldRetry: boolean; 25 | 26 | constructor(message: string, shouldRetry?: boolean, error?: Error) { 27 | super(message, error); 28 | this.shouldRetry = shouldRetry || false; 29 | } 30 | } 31 | 32 | export class DocumentTranslationError extends DeepLError { 33 | public readonly documentHandle?: DocumentHandle; 34 | 35 | constructor(message: string, handle?: DocumentHandle, error?: Error) { 36 | super(message, error); 37 | this.documentHandle = handle; 38 | } 39 | } 40 | 41 | export class GlossaryNotFoundError extends DeepLError {} 42 | 43 | export class DocumentNotReadyError extends DeepLError {} 44 | 45 | /** 46 | * Error thrown if an error occurs during the minification phase. 47 | * @see DocumentMinifier.minifyDocument 48 | */ 49 | export class DocumentMinificationError extends DeepLError {} 50 | 51 | /** 52 | * Error thrown if an error occurs during the deminification phase. 53 | * @see DocumentMinifier.deminifyDocument 54 | */ 55 | export class DocumentDeminificationError extends DeepLError {} 56 | 57 | export class ArgumentError extends DeepLError {} 58 | -------------------------------------------------------------------------------- /src/fsHelper.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | /** 4 | * This class is necessary because some fs library methods and/or params are not available in older versions of node, such as v12 5 | * 6 | * Docs for v12: https://nodejs.org/docs/latest-v12.x/api/fs.html#fspromisesreaddirpath-options 7 | */ 8 | export class FsHelper { 9 | public static readdirSyncRecursive(filepath: string): string[] { 10 | if (!fs.existsSync(filepath)) { 11 | throw new Error(`Error: no such file or directory, ${filepath}`); 12 | } 13 | 14 | if (!fs.statSync(filepath).isDirectory()) { 15 | throw new Error(`Error: not a directory, ${filepath}`); 16 | } 17 | 18 | const results: string[] = []; 19 | const filesAndDirs = fs.readdirSync(filepath); 20 | 21 | filesAndDirs.forEach((fileOrDir) => { 22 | const isDir = fs.statSync(path.join(filepath, fileOrDir)).isDirectory(); 23 | if (isDir) { 24 | const dir = fileOrDir; 25 | const dirList = this.readdirSyncRecursive(path.join(filepath, fileOrDir)); 26 | const subList = dirList.map((subpath) => `${dir}/${subpath}`); 27 | results.push(dir, ...subList); 28 | } else { 29 | const file = fileOrDir; 30 | results.push(file); 31 | } 32 | }); 33 | 34 | return results; 35 | } 36 | 37 | public static removeSyncRecursive(filepath: string): void { 38 | if (!fs.existsSync(filepath)) { 39 | throw new Error(`Error: no such file or directory, ${filepath}`); 40 | } 41 | 42 | const stat = fs.statSync(filepath); 43 | if (!stat.isDirectory()) { 44 | fs.unlinkSync(filepath); 45 | } else { 46 | // Note: it's okay to use the native readdirSync here because we do not need the recursive functionality 47 | const filesAndDirs = fs.readdirSync(filepath); 48 | filesAndDirs.forEach((file) => { 49 | this.removeSyncRecursive(path.join(filepath, file)); 50 | }); 51 | fs.rmdirSync(filepath); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/glossaryEntries.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 DeepL SE (https://www.deepl.com) 2 | // Use of this source code is governed by an MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | import { DeepLError } from './errors'; 6 | import { isString } from './utils'; 7 | 8 | /** 9 | * Stores the entries of a glossary. 10 | */ 11 | export class GlossaryEntries { 12 | private implEntries: Record; 13 | 14 | /** 15 | * Construct a GlossaryEntries object containing the specified entries as an object or a 16 | * tab-separated values (TSV) string. The entries and tsv options are mutually exclusive. 17 | * @param options Controls how to create glossary entries. If options is unspecified, no 18 | * entries will be created. 19 | * @param options.entries Object containing fields storing entries, for example: 20 | * `{'Hello': 'Hallo'}`. 21 | * @param options.tsv String containing TSV to parse. Each line should contain a source and 22 | * target term separated by a tab. Empty lines are ignored. 23 | * @return GlossaryEntries object containing parsed entries. 24 | * @throws DeepLError If given entries contain invalid characters. 25 | */ 26 | constructor(options?: { tsv?: string; entries?: Record }) { 27 | this.implEntries = {}; 28 | 29 | if (options?.entries !== undefined) { 30 | if (options?.tsv !== undefined) { 31 | throw new DeepLError('options.entries and options.tsv are mutually exclusive'); 32 | } 33 | Object.assign(this.implEntries, options.entries); 34 | } else if (options?.tsv !== undefined) { 35 | const tsv = options.tsv; 36 | for (const entry of tsv.split(/\r\n|\n|\r/)) { 37 | if (entry.length === 0) { 38 | continue; 39 | } 40 | 41 | const [source, target, extra] = entry.split('\t', 3); 42 | if (target === undefined) { 43 | throw new DeepLError(`Missing tab character in entry '${entry}'`); 44 | } else if (extra !== undefined) { 45 | throw new DeepLError(`Duplicate tab character in entry '${entry}'`); 46 | } 47 | this.add(source, target, false); 48 | } 49 | } 50 | } 51 | 52 | /** 53 | * Add the specified source-target entry. 54 | * @param source Source term of the glossary entry. 55 | * @param target Target term of the glossary entry. 56 | * @param overwrite If false, throw an error if the source entry already exists. 57 | */ 58 | public add(source: string, target: string, overwrite = false) { 59 | if (!overwrite && source in this.implEntries) { 60 | throw new DeepLError(`Duplicate source term '${source}'`); 61 | } 62 | this.implEntries[source] = target; 63 | } 64 | 65 | /** 66 | * Retrieve the contained entries. 67 | */ 68 | public entries(): Record { 69 | return this.implEntries; 70 | } 71 | 72 | /** 73 | * Converts glossary entries to a tab-separated values (TSV) string. 74 | * @return string containing entries in TSV format. 75 | * @throws {Error} If any glossary entries are invalid. 76 | */ 77 | public toTsv(): string { 78 | return Object.entries(this.implEntries) 79 | .map(([source, target]) => { 80 | GlossaryEntries.validateGlossaryTerm(source); 81 | GlossaryEntries.validateGlossaryTerm(target); 82 | return `${source}\t${target}`; 83 | }) 84 | .join('\n'); 85 | } 86 | 87 | /** 88 | * Checks if the given glossary term contains any disallowed characters. 89 | * @param term Glossary term to check for validity. 90 | * @throws {Error} If the term is not valid or a disallowed character is found. 91 | */ 92 | static validateGlossaryTerm(term: string) { 93 | if (!isString(term) || term.length === 0) { 94 | throw new DeepLError(`'${term}' is not a valid term.`); 95 | } 96 | for (let idx = 0; idx < term.length; idx++) { 97 | const charCode = term.charCodeAt(idx); 98 | if ( 99 | (0 <= charCode && charCode <= 31) || // C0 control characters 100 | (128 <= charCode && charCode <= 159) || // C1 control characters 101 | charCode === 0x2028 || 102 | charCode === 0x2029 // Unicode newlines 103 | ) { 104 | throw new DeepLError( 105 | `Term '${term}' contains invalid character: '${term[idx]}' (${charCode})`, 106 | ); 107 | } 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 DeepL SE (https://www.deepl.com) 2 | // Use of this source code is governed by an MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | import { HttpClient } from './client'; 6 | import { 7 | AuthorizationError, 8 | DeepLError, 9 | DocumentNotReadyError, 10 | DocumentTranslationError, 11 | GlossaryNotFoundError, 12 | QuotaExceededError, 13 | TooManyRequestsError, 14 | } from './errors'; 15 | import { GlossaryEntries } from './glossaryEntries'; 16 | import { 17 | parseDocumentHandle, 18 | parseDocumentStatus, 19 | parseGlossaryInfo, 20 | parseGlossaryInfoList, 21 | parseGlossaryLanguagePairArray, 22 | parseLanguageArray, 23 | parseTextResultArray, 24 | parseUsage, 25 | } from './parsing'; 26 | import { Translator } from './translator'; 27 | import { 28 | AppInfo, 29 | DocumentTranslateOptions, 30 | Formality, 31 | GlossaryId, 32 | GlossaryInfo, 33 | LanguageCode, 34 | NonRegionalLanguageCode, 35 | RequestParameters, 36 | SentenceSplittingMode, 37 | SourceGlossaryLanguageCode, 38 | SourceLanguageCode, 39 | TagList, 40 | TargetGlossaryLanguageCode, 41 | TargetLanguageCode, 42 | TranslateTextOptions, 43 | TranslatorOptions, 44 | } from './types'; 45 | import { isString, logInfo, streamToBuffer, streamToString, timeout, toBoolString } from './utils'; 46 | 47 | import * as fs from 'fs'; 48 | import { IncomingMessage, STATUS_CODES } from 'http'; 49 | import path from 'path'; 50 | import * as os from 'os'; 51 | import { URLSearchParams } from 'url'; 52 | import * as util from 'util'; 53 | 54 | export * from './errors'; 55 | export * from './glossaryEntries'; 56 | export * from './types'; 57 | export * from './deeplClient'; 58 | export * from './translator'; 59 | -------------------------------------------------------------------------------- /src/parsing.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 DeepL SE (https://www.deepl.com) 2 | // Use of this source code is governed by an MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | import { GlossaryEntries } from './glossaryEntries'; 6 | import { DeepLError } from './errors'; 7 | import { 8 | DocumentHandle, 9 | DocumentStatus, 10 | DocumentStatusCode, 11 | GlossaryLanguagePair, 12 | Language, 13 | SourceGlossaryLanguageCode, 14 | TextResult, 15 | TargetGlossaryLanguageCode, 16 | UsageDetail, 17 | Usage, 18 | GlossaryInfo, 19 | SourceLanguageCode, 20 | WriteResult, 21 | MultilingualGlossaryInfo, 22 | MultilingualGlossaryDictionaryInfo, 23 | MultilingualGlossaryDictionaryEntries, 24 | MultilingualGlossaryDictionaryApiResponse, 25 | MultilingualGlossaryDictionaryEntriesApiResponse, 26 | ListMultilingualGlossaryApiResponse, 27 | } from './types'; 28 | import { standardizeLanguageCode } from './utils'; 29 | 30 | /** 31 | * Type used during JSON parsing of API response for glossary info. 32 | * @private 33 | */ 34 | interface GlossaryInfoApiResponse { 35 | glossary_id: string; 36 | name: string; 37 | ready: boolean; 38 | source_lang: string; 39 | target_lang: string; 40 | creation_time: string; 41 | entry_count: number; 42 | } 43 | 44 | /** 45 | * Type used during JSON parsing of API response for multilingual glossary info. 46 | * @private 47 | */ 48 | interface MultilingualGlossaryInfoApiResponse { 49 | glossary_id: string; 50 | name: string; 51 | dictionaries: MultilingualGlossaryDictionaryApiResponse[]; 52 | creation_time: string; 53 | } 54 | 55 | /** 56 | * Type used during JSON parsing of API response for lists of glossary infos. 57 | * @private 58 | */ 59 | interface GlossaryInfoListApiResponse { 60 | glossaries: GlossaryInfoApiResponse[]; 61 | } 62 | 63 | /** 64 | * Type used during JSON parsing of API response for usage. 65 | * @private 66 | */ 67 | interface UsageApiResponse { 68 | character_count?: number; 69 | character_limit?: number; 70 | document_count?: number; 71 | document_limit?: number; 72 | team_document_count?: number; 73 | team_document_limit?: number; 74 | } 75 | 76 | /** 77 | * Type used during JSON parsing of API response for text translation results. 78 | * @private 79 | */ 80 | interface TextResultApiResponse { 81 | text: string; 82 | detected_source_language: string; 83 | billed_characters: number; 84 | model_type_used?: string; 85 | } 86 | 87 | /** 88 | * Type used during JSON parsing of API response for lists of text translation results. 89 | * @private 90 | */ 91 | interface TextResultArrayApiResponse { 92 | translations: TextResultApiResponse[]; 93 | } 94 | 95 | /** 96 | * Type used during JSON parsing of API response for text translation results. 97 | * @private 98 | */ 99 | interface WriteResultApiResponse { 100 | text: string; 101 | detected_source_language: string; 102 | target_language: string; 103 | } 104 | 105 | /** 106 | * Type used during JSON parsing of API response for lists of text rephrasing results. 107 | * @private 108 | */ 109 | interface WriteResultArrayApiResponse { 110 | improvements: WriteResultApiResponse[]; 111 | } 112 | 113 | /** 114 | * Type used during JSON parsing of API response for languages. 115 | * @private 116 | */ 117 | interface LanguageApiResponse { 118 | language: string; 119 | name: string; 120 | supports_formality?: boolean; 121 | } 122 | 123 | /** 124 | * Type used during JSON parsing of API response for glossary language pairs. 125 | * @private 126 | */ 127 | interface GlossaryLanguagePairApiResponse { 128 | source_lang: string; 129 | target_lang: string; 130 | } 131 | 132 | /** 133 | * Type used during JSON parsing of API response for lists of glossary language pairs. 134 | * @private 135 | */ 136 | interface GlossaryLanguagePairArrayApiResponse { 137 | supported_languages: GlossaryInfoApiResponse[]; 138 | } 139 | 140 | /** 141 | * Type used during JSON parsing of API response for multilingual glossary info. 142 | * @private 143 | */ 144 | interface MultilingualGlossaryInfoApiResponse { 145 | glossary_id: string; 146 | name: string; 147 | dictionaries: MultilingualGlossaryDictionaryApiResponse[]; 148 | creation_time: string; 149 | } 150 | 151 | /** 152 | * Type used during JSON parsing of API response for document translation statuses. 153 | * @private 154 | */ 155 | interface DocumentStatusApiResponse { 156 | status: string; 157 | seconds_remaining?: number; 158 | billed_characters?: number; 159 | error_message?: string; 160 | } 161 | 162 | /** 163 | * Type used during JSON parsing of API response for document handles. 164 | * @private 165 | */ 166 | interface DocumentHandleApiResponse { 167 | document_id: string; 168 | document_key: string; 169 | } 170 | 171 | class UsageDetailImpl implements UsageDetail { 172 | public count: number; 173 | public limit: number; 174 | 175 | /** 176 | * @private Package users should not need to construct this class. 177 | */ 178 | constructor(count: number, limit: number) { 179 | this.count = count; 180 | this.limit = limit; 181 | } 182 | 183 | limitReached(): boolean { 184 | return this.count >= this.limit; 185 | } 186 | } 187 | 188 | class UsageImpl implements Usage { 189 | public character?: UsageDetail; 190 | public document?: UsageDetail; 191 | public teamDocument?: UsageDetail; 192 | 193 | /** 194 | * @private Package users should not need to construct this class. 195 | */ 196 | constructor(character?: UsageDetail, document?: UsageDetail, teamDocument?: UsageDetail) { 197 | this.character = character; 198 | this.document = document; 199 | this.teamDocument = teamDocument; 200 | } 201 | 202 | /** Returns true if any usage type limit has been reached or passed, otherwise false. */ 203 | anyLimitReached(): boolean { 204 | return ( 205 | this.character?.limitReached() || 206 | this.document?.limitReached() || 207 | this.teamDocument?.limitReached() || 208 | false 209 | ); 210 | } 211 | 212 | /** Converts the usage details to a human-readable string. */ 213 | toString(): string { 214 | const labelledDetails: Array<[string, UsageDetail?]> = [ 215 | ['Characters', this.character], 216 | ['Documents', this.document], 217 | ['Team documents', this.teamDocument], 218 | ]; 219 | const detailsString = labelledDetails 220 | .filter(([, detail]) => detail) 221 | .map( 222 | ([label, detail]) => 223 | `${label}: ${(detail as UsageDetail).count} of ${ 224 | (detail as UsageDetail).limit 225 | }`, 226 | ); 227 | return 'Usage this billing period:\n' + detailsString.join('\n'); 228 | } 229 | } 230 | 231 | class DocumentStatusImpl implements DocumentStatus { 232 | public status: DocumentStatusCode; 233 | public secondsRemaining?: number; 234 | public billedCharacters?: number; 235 | public errorMessage?: string; 236 | 237 | constructor( 238 | status: DocumentStatusCode, 239 | secondsRemaining?: number, 240 | billedCharacters?: number, 241 | errorMessage?: string, 242 | ) { 243 | this.status = status; 244 | this.secondsRemaining = secondsRemaining; 245 | this.billedCharacters = billedCharacters; 246 | this.errorMessage = errorMessage; 247 | } 248 | 249 | ok(): boolean { 250 | return this.status === 'queued' || this.status === 'translating' || this.status === 'done'; 251 | } 252 | 253 | done(): boolean { 254 | return this.status === 'done'; 255 | } 256 | } 257 | 258 | /** 259 | * Parses the given glossary info API response to a GlossaryInfo object. 260 | * @private 261 | */ 262 | function parseRawGlossaryInfo(obj: GlossaryInfoApiResponse): GlossaryInfo { 263 | return { 264 | glossaryId: obj.glossary_id, 265 | name: obj.name, 266 | ready: obj.ready, 267 | sourceLang: obj.source_lang as SourceGlossaryLanguageCode, 268 | targetLang: obj.target_lang as TargetGlossaryLanguageCode, 269 | creationTime: new Date(obj.creation_time), 270 | entryCount: obj.entry_count, 271 | }; 272 | } 273 | 274 | /** 275 | * Parses the given multilingual glossary info API response to a GlossaryInfo object. 276 | * @private 277 | */ 278 | export function parseMultilingualGlossaryDictionaryInfo( 279 | obj: MultilingualGlossaryDictionaryApiResponse, 280 | ): MultilingualGlossaryDictionaryInfo { 281 | return { 282 | sourceLangCode: obj.source_lang as SourceGlossaryLanguageCode, 283 | targetLangCode: obj.target_lang as TargetGlossaryLanguageCode, 284 | entryCount: obj.entry_count, 285 | }; 286 | } 287 | 288 | /** 289 | * Parses the given multilingual glossary info API response to a GlossaryInfo object. 290 | * @private 291 | */ 292 | function parseRawMultilingualGlossaryInfo( 293 | obj: MultilingualGlossaryInfoApiResponse, 294 | ): MultilingualGlossaryInfo { 295 | return { 296 | glossaryId: obj.glossary_id, 297 | name: obj.name, 298 | creationTime: new Date(obj.creation_time), 299 | dictionaries: obj.dictionaries.map((dict) => parseMultilingualGlossaryDictionaryInfo(dict)), 300 | }; 301 | } 302 | 303 | /** 304 | * Parses the given multilingual glossary entries API response to a GlossaryDictionaryEntries object. 305 | * @private 306 | */ 307 | export function parseMultilingualGlossaryDictionaryEntries( 308 | obj: MultilingualGlossaryDictionaryEntriesApiResponse, 309 | ): MultilingualGlossaryDictionaryEntries[] { 310 | return obj.dictionaries.map((dict) => ({ 311 | sourceLangCode: dict.source_lang as SourceGlossaryLanguageCode, 312 | targetLangCode: dict.target_lang as TargetGlossaryLanguageCode, 313 | entries: new GlossaryEntries({ tsv: dict.entries }), 314 | })); 315 | } 316 | 317 | /** 318 | * Parses the given list multilingual glossaries API response. 319 | * @private 320 | */ 321 | export function parseListMultilingualGlossaries( 322 | obj: ListMultilingualGlossaryApiResponse, 323 | ): MultilingualGlossaryInfo[] { 324 | return obj.glossaries.map((glossary) => ({ 325 | glossaryId: glossary.glossary_id, 326 | name: glossary.name, 327 | dictionaries: glossary.dictionaries.map((dict) => 328 | parseMultilingualGlossaryDictionaryInfo(dict), 329 | ), 330 | creationTime: new Date(glossary.creation_time), 331 | })); 332 | } 333 | 334 | /** 335 | * Parses the given JSON string to a GlossaryInfo object. 336 | * @private 337 | */ 338 | export function parseGlossaryInfo(json: string): GlossaryInfo { 339 | try { 340 | const obj = JSON.parse(json) as GlossaryInfoApiResponse; 341 | return parseRawGlossaryInfo(obj); 342 | } catch (error) { 343 | throw new DeepLError(`Error parsing response JSON: ${error}`); 344 | } 345 | } 346 | 347 | /** 348 | * Parses the given JSON string to a MultilingualGlossaryInfo object. 349 | * @private 350 | */ 351 | export function parseMultilingualGlossaryInfo(json: string): MultilingualGlossaryInfo { 352 | try { 353 | const obj = JSON.parse(json) as MultilingualGlossaryInfoApiResponse; 354 | return parseRawMultilingualGlossaryInfo(obj); 355 | } catch (error) { 356 | throw new DeepLError(`Error parsing response JSON: ${error}`); 357 | } 358 | } 359 | 360 | /** 361 | * Parses the given JSON string to an array of GlossaryInfo objects. 362 | * @private 363 | */ 364 | export function parseGlossaryInfoList(json: string): GlossaryInfo[] { 365 | try { 366 | const obj = JSON.parse(json) as GlossaryInfoListApiResponse; 367 | return obj.glossaries.map((rawGlossaryInfo: GlossaryInfoApiResponse) => 368 | parseRawGlossaryInfo(rawGlossaryInfo), 369 | ); 370 | } catch (error) { 371 | throw new DeepLError(`Error parsing response JSON: ${error}`); 372 | } 373 | } 374 | 375 | /** 376 | * Parses the given JSON string to a DocumentStatus object. 377 | * @private 378 | */ 379 | export function parseDocumentStatus(json: string): DocumentStatus { 380 | try { 381 | const obj = JSON.parse(json) as DocumentStatusApiResponse; 382 | return new DocumentStatusImpl( 383 | obj.status as DocumentStatusCode, 384 | obj.seconds_remaining, 385 | obj.billed_characters, 386 | obj.error_message, 387 | ); 388 | } catch (error) { 389 | throw new DeepLError(`Error parsing response JSON: ${error}`); 390 | } 391 | } 392 | 393 | /** 394 | * Parses the given usage API response to a UsageDetail object, which forms part of a Usage object. 395 | * @private 396 | */ 397 | function parseUsageDetail( 398 | obj: UsageApiResponse, 399 | prefix: 'character' | 'document' | 'team_document', 400 | ): UsageDetail | undefined { 401 | const count = obj[`${prefix}_count`]; 402 | const limit = obj[`${prefix}_limit`]; 403 | if (count === undefined || limit === undefined) return undefined; 404 | return new UsageDetailImpl(count, limit); 405 | } 406 | 407 | /** 408 | * Parses the given JSON string to a Usage object. 409 | * @private 410 | */ 411 | export function parseUsage(json: string): Usage { 412 | try { 413 | const obj = JSON.parse(json) as UsageApiResponse; 414 | return new UsageImpl( 415 | parseUsageDetail(obj, 'character'), 416 | parseUsageDetail(obj, 'document'), 417 | parseUsageDetail(obj, 'team_document'), 418 | ); 419 | } catch (error) { 420 | throw new DeepLError(`Error parsing response JSON: ${error}`); 421 | } 422 | } 423 | 424 | /** 425 | * Parses the given JSON string to an array of TextResult objects. 426 | * @private 427 | */ 428 | export function parseTextResultArray(json: string): TextResult[] { 429 | try { 430 | const obj = JSON.parse(json) as TextResultArrayApiResponse; 431 | return obj.translations.map((translation: TextResultApiResponse) => { 432 | return { 433 | text: translation.text, 434 | detectedSourceLang: standardizeLanguageCode( 435 | translation.detected_source_language, 436 | ) as SourceLanguageCode, 437 | billedCharacters: translation.billed_characters, 438 | modelTypeUsed: translation.model_type_used, 439 | }; 440 | }); 441 | } catch (error) { 442 | throw new DeepLError(`Error parsing response JSON: ${error}`); 443 | } 444 | } 445 | 446 | export function parseWriteResultArray(json: string): WriteResult[] { 447 | try { 448 | const obj = JSON.parse(json) as WriteResultArrayApiResponse; 449 | return obj.improvements.map((improvement: WriteResultApiResponse) => { 450 | return { 451 | text: improvement.text, 452 | detectedSourceLang: standardizeLanguageCode( 453 | improvement.detected_source_language, 454 | ) as SourceLanguageCode, 455 | targetLang: improvement.target_language, 456 | }; 457 | }); 458 | } catch (error) { 459 | throw new DeepLError(`Error parsing response JSON: ${error}`); 460 | } 461 | } 462 | 463 | /** 464 | * Parses the given language API response to a Language object. 465 | * @private 466 | */ 467 | function parseLanguage(lang: LanguageApiResponse): Language { 468 | try { 469 | const result = { 470 | name: lang.name, 471 | code: standardizeLanguageCode(lang.language), 472 | supportsFormality: lang.supports_formality, 473 | }; 474 | if (result.supportsFormality === undefined) { 475 | delete result.supportsFormality; 476 | } 477 | return result; 478 | } catch (error) { 479 | throw new DeepLError(`Error parsing response JSON: ${error}`); 480 | } 481 | } 482 | 483 | /** 484 | * Parses the given JSON string to an array of Language objects. 485 | * @private 486 | */ 487 | export function parseLanguageArray(json: string): Language[] { 488 | const obj = JSON.parse(json) as LanguageApiResponse[]; 489 | return obj.map((lang: LanguageApiResponse) => parseLanguage(lang)); 490 | } 491 | 492 | /** 493 | * Parses the given glossary language pair API response to a GlossaryLanguagePair object. 494 | * @private 495 | */ 496 | function parseGlossaryLanguagePair(obj: GlossaryLanguagePairApiResponse): GlossaryLanguagePair { 497 | try { 498 | return { 499 | sourceLang: obj.source_lang as SourceGlossaryLanguageCode, 500 | targetLang: obj.target_lang as TargetGlossaryLanguageCode, 501 | }; 502 | } catch (error) { 503 | throw new DeepLError(`Error parsing response JSON: ${error}`); 504 | } 505 | } 506 | 507 | /** 508 | * Parses the given JSON string to an array of GlossaryLanguagePair objects. 509 | * @private 510 | */ 511 | export function parseGlossaryLanguagePairArray(json: string): GlossaryLanguagePair[] { 512 | const obj = JSON.parse(json) as GlossaryLanguagePairArrayApiResponse; 513 | return obj.supported_languages.map((langPair: GlossaryLanguagePairApiResponse) => 514 | parseGlossaryLanguagePair(langPair), 515 | ); 516 | } 517 | 518 | /** 519 | * Parses the given JSON string to a DocumentHandle object. 520 | * @private 521 | */ 522 | export function parseDocumentHandle(json: string): DocumentHandle { 523 | try { 524 | const obj = JSON.parse(json) as DocumentHandleApiResponse; 525 | return { documentId: obj.document_id, documentKey: obj.document_key }; 526 | } catch (error) { 527 | throw new DeepLError(`Error parsing response JSON: ${error}`); 528 | } 529 | } 530 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 DeepL SE (https://www.deepl.com) 2 | // Use of this source code is governed by an MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | import loglevel from 'loglevel'; 6 | import { DeepLError } from './errors'; 7 | import { 8 | Formality, 9 | GlossaryInfo, 10 | GlossaryId, 11 | LanguageCode, 12 | NonRegionalLanguageCode, 13 | RequestParameters, 14 | SentenceSplittingMode, 15 | TagList, 16 | TranslateTextOptions, 17 | MultilingualGlossaryInfo, 18 | MultilingualGlossaryDictionaryEntries, 19 | } from './types'; 20 | 21 | const logger = loglevel.getLogger('deepl'); 22 | 23 | function concatLoggingArgs(args?: object): string { 24 | let detail = ''; 25 | if (args) { 26 | for (const [key, value] of Object.entries(args)) { 27 | detail += `, ${key} = ${value}`; 28 | } 29 | } 30 | return detail; 31 | } 32 | 33 | export function logDebug(message: string, args?: object) { 34 | logger.debug(message + concatLoggingArgs(args)); 35 | } 36 | 37 | export function logInfo(message: string, args?: object) { 38 | logger.info(message + concatLoggingArgs(args)); 39 | } 40 | 41 | /** 42 | * Converts contents of given stream to a Buffer. 43 | * @private 44 | */ 45 | export async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { 46 | const chunks: Buffer[] = []; 47 | return new Promise((resolve, reject) => { 48 | stream.on('data', (chunk: Buffer) => chunks.push(chunk)); 49 | stream.on('error', (err) => reject(err)); 50 | stream.on('end', () => resolve(Buffer.concat(chunks))); 51 | }); 52 | } 53 | 54 | /** 55 | * Converts contents of given stream to a string using UTF-8 encoding. 56 | * @private 57 | */ 58 | export async function streamToString(stream: NodeJS.ReadableStream): Promise { 59 | return (await streamToBuffer(stream)).toString('utf8'); 60 | } 61 | 62 | // Wrap setTimeout() with Promise 63 | export const timeout = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 64 | 65 | /** 66 | * Returns true if the given argument is a string. 67 | * @param arg Argument to check. 68 | */ 69 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 70 | export function isString(arg: any): arg is string { 71 | return typeof arg === 'string'; 72 | } 73 | 74 | /** 75 | * Returns '1' if the given arg is truthy, '0' otherwise. 76 | * @param arg Argument to check. 77 | */ 78 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 79 | export function toBoolString(arg: any): string { 80 | return arg ? '1' : '0'; 81 | } 82 | 83 | /** 84 | * Returns true if the specified DeepL Authentication Key is associated with a free account, 85 | * otherwise false. 86 | * @param authKey The authentication key to check. 87 | * @return True if the key is associated with a free account, otherwise false. 88 | */ 89 | export function isFreeAccountAuthKey(authKey: string): boolean { 90 | return authKey.endsWith(':fx'); 91 | } 92 | 93 | /** 94 | * Changes the upper- and lower-casing of the given language code to match ISO 639-1 with an 95 | * optional regional code from ISO 3166-1. 96 | * For example, input 'EN-US' returns 'en-US'. 97 | * @param langCode String containing language code to standardize. 98 | * @return Standardized language code. 99 | */ 100 | export function standardizeLanguageCode(langCode: string): LanguageCode { 101 | if (!isString(langCode) || langCode.length === 0) { 102 | throw new DeepLError('langCode must be a non-empty string'); 103 | } 104 | const [lang, region] = langCode.split('-', 2); 105 | return ( 106 | region === undefined ? lang.toLowerCase() : `${lang.toLowerCase()}-${region.toUpperCase()}` 107 | ) as LanguageCode; 108 | } 109 | 110 | /** 111 | * Removes the regional variant from a language, for example inputs 'en' and 'en-US' both return 112 | * 'en'. 113 | * @param langCode String containing language code to convert. 114 | * @return Language code with regional variant removed. 115 | */ 116 | export function nonRegionalLanguageCode(langCode: string): NonRegionalLanguageCode { 117 | if (!isString(langCode) || langCode.length === 0) { 118 | throw new DeepLError('langCode must be a non-empty string'); 119 | } 120 | return langCode.split('-', 2)[0].toLowerCase() as NonRegionalLanguageCode; 121 | } 122 | 123 | /** 124 | * Joins given TagList with commas to form a single comma-delimited string. 125 | * @private 126 | */ 127 | function joinTagList(tagList: TagList): string { 128 | if (isString(tagList)) { 129 | return tagList; 130 | } else { 131 | return tagList.join(','); 132 | } 133 | } 134 | 135 | /** 136 | * Validates and prepares URLSearchParams for arguments common to text and document translation. 137 | * @private 138 | */ 139 | export function buildURLSearchParams( 140 | sourceLang: LanguageCode | null, 141 | targetLang: LanguageCode, 142 | formality: Formality | undefined, 143 | glossary: GlossaryId | GlossaryInfo | MultilingualGlossaryInfo | undefined, 144 | extraRequestParameters: RequestParameters | undefined, 145 | ): URLSearchParams { 146 | targetLang = standardizeLanguageCode(targetLang); 147 | if (sourceLang !== null) { 148 | sourceLang = standardizeLanguageCode(sourceLang); 149 | } 150 | 151 | if (glossary !== undefined && sourceLang === null) { 152 | throw new DeepLError('sourceLang is required if using a glossary'); 153 | } 154 | 155 | if (targetLang === 'en') { 156 | throw new DeepLError( 157 | "targetLang='en' is deprecated, please use 'en-GB' or 'en-US' instead.", 158 | ); 159 | } else if (targetLang === 'pt') { 160 | throw new DeepLError( 161 | "targetLang='pt' is deprecated, please use 'pt-PT' or 'pt-BR' instead.", 162 | ); 163 | } 164 | 165 | const searchParams = new URLSearchParams({ 166 | target_lang: targetLang, 167 | }); 168 | if (sourceLang !== null) { 169 | searchParams.append('source_lang', sourceLang); 170 | } 171 | if (formality !== undefined) { 172 | const formalityStr = String(formality).toLowerCase(); 173 | searchParams.append('formality', formalityStr); 174 | } 175 | if (glossary !== undefined) { 176 | if (!isString(glossary)) { 177 | if (glossary.glossaryId === undefined) { 178 | throw new DeepLError( 179 | 'glossary option should be a string containing the Glossary ID or a GlossaryInfo object.', 180 | ); 181 | } 182 | glossary = glossary.glossaryId; 183 | } 184 | searchParams.append('glossary_id', glossary); 185 | } 186 | if (extraRequestParameters !== undefined) { 187 | for (const paramName in extraRequestParameters) { 188 | searchParams.append(paramName, extraRequestParameters[paramName]); 189 | } 190 | } 191 | return searchParams; 192 | } 193 | 194 | /** 195 | * Validates and appends texts to HTTP request parameters, and returns whether a single text 196 | * argument was provided. 197 | * @param data Parameters for HTTP request. 198 | * @param texts User-supplied texts to be checked. 199 | * @return True if only a single text was provided. 200 | * @private 201 | */ 202 | export function appendTextsAndReturnIsSingular( 203 | data: URLSearchParams, 204 | texts: string | string[], 205 | ): boolean { 206 | const singular = !Array.isArray(texts); 207 | if (singular) { 208 | if (!isString(texts) || texts.length === 0) { 209 | throw new DeepLError( 210 | 'texts parameter must be a non-empty string or array of non-empty strings', 211 | ); 212 | } 213 | data.append('text', texts); 214 | } else { 215 | for (const text of texts) { 216 | if (!isString(text) || text.length === 0) { 217 | throw new DeepLError( 218 | 'texts parameter must be a non-empty string or array of non-empty strings', 219 | ); 220 | } 221 | data.append('text', text); 222 | } 223 | } 224 | return singular; 225 | } 226 | 227 | /** 228 | * Validates and appends text options to HTTP request parameters. 229 | * @param data Parameters for HTTP request. 230 | * @param options Options for translate text request. 231 | * Note the formality and glossaryId options are handled separately, because these options 232 | * overlap with the translateDocument function. 233 | * @private 234 | */ 235 | export function validateAndAppendTextOptions( 236 | data: URLSearchParams, 237 | options?: TranslateTextOptions, 238 | ) { 239 | if (!options) { 240 | return; 241 | } 242 | if (options.splitSentences !== undefined) { 243 | options.splitSentences = options.splitSentences.toLowerCase() as SentenceSplittingMode; 244 | if (options.splitSentences === 'on' || options.splitSentences === 'default') { 245 | data.append('split_sentences', '1'); 246 | } else if (options.splitSentences === 'off') { 247 | data.append('split_sentences', '0'); 248 | } else { 249 | data.append('split_sentences', options.splitSentences); 250 | } 251 | } 252 | if (options.preserveFormatting !== undefined) { 253 | data.append('preserve_formatting', toBoolString(options.preserveFormatting)); 254 | } 255 | if (options.tagHandling !== undefined) { 256 | data.append('tag_handling', options.tagHandling); 257 | } 258 | if (options.outlineDetection !== undefined) { 259 | data.append('outline_detection', toBoolString(options.outlineDetection)); 260 | } 261 | if (options.context !== undefined) { 262 | data.append('context', options.context); 263 | } 264 | if (options.modelType !== undefined) { 265 | data.append('model_type', options.modelType); 266 | } 267 | if (options.nonSplittingTags !== undefined) { 268 | data.append('non_splitting_tags', joinTagList(options.nonSplittingTags)); 269 | } 270 | if (options.splittingTags !== undefined) { 271 | data.append('splitting_tags', joinTagList(options.splittingTags)); 272 | } 273 | if (options.ignoreTags !== undefined) { 274 | data.append('ignore_tags', joinTagList(options.ignoreTags)); 275 | } 276 | } 277 | 278 | /** 279 | * Appends glossary dictionaries to HTTP request parameters. 280 | * @param data URL-encoded parameters for a HTTP request. 281 | * @param dictionaries Glossary dictionaries to append. 282 | * @private 283 | */ 284 | export function appendDictionaryEntries( 285 | data: URLSearchParams, 286 | dictionaries: MultilingualGlossaryDictionaryEntries[], 287 | ): void { 288 | dictionaries.forEach((dict, index) => { 289 | data.append(`dictionaries[${index}].source_lang`, dict.sourceLangCode); 290 | data.append(`dictionaries[${index}].target_lang`, dict.targetLangCode); 291 | data.append(`dictionaries[${index}].entries`, dict.entries.toTsv()); 292 | data.append(`dictionaries[${index}].entries_format`, 'tsv'); 293 | }); 294 | } 295 | /** 296 | * Appends a glossary dictionary with CSV entries to HTTP request parameters. 297 | * @param data URL-encoded parameters for a HTTP request. 298 | * @param sourceLanguageCode Source language code of the dictionary. 299 | * @param targetLanguageCode Target language code of the dictionary. 300 | * @param csvContent CSV-formatted string containing the dictionary entries. 301 | * @private 302 | */ 303 | export function appendCsvDictionaryEntries( 304 | data: URLSearchParams, 305 | sourceLanguageCode: string, 306 | targetLanguageCode: string, 307 | csvContent: string, 308 | ): void { 309 | data.append('dictionaries[0].source_lang', sourceLanguageCode); 310 | data.append('dictionaries[0].target_lang', targetLanguageCode); 311 | data.append('dictionaries[0].entries', csvContent); 312 | data.append('dictionaries[0].entries_format', 'csv'); 313 | } 314 | 315 | /** 316 | * Extract the glossary ID from the argument. 317 | * @param glossary The glossary as a string, GlossaryInfo, or MultilingualGlossaryInfo. 318 | * @private 319 | */ 320 | export function extractGlossaryId(glossary: GlossaryId | GlossaryInfo | MultilingualGlossaryInfo) { 321 | return typeof glossary === 'string' ? glossary : glossary.glossaryId; 322 | } 323 | -------------------------------------------------------------------------------- /tests/client.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2025 DeepL SE (https://www.deepl.com) 2 | // Use of this source code is governed by an MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | import * as deepl from 'deepl-node'; 6 | 7 | import { exampleText, makeDeeplClient, makeTranslator } from './core'; 8 | import log from 'loglevel'; 9 | 10 | jest.mock('loglevel', () => ({ 11 | debug: jest.fn(), 12 | getLogger: jest.fn().mockReturnValue({ 13 | setLevel: jest.fn(), 14 | debug: jest.fn(), 15 | info: jest.fn(), 16 | }), 17 | })); 18 | 19 | describe('client tests', () => { 20 | describe('log debug', () => { 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | log.getLogger('deepl').setLevel('debug'); 24 | }); 25 | 26 | it('with translate text', async () => { 27 | const translator = makeTranslator({}); 28 | const result = await translator.translateText(exampleText.en, null, 'de'); 29 | 30 | expect(log.getLogger('deepl').debug).toHaveBeenCalledTimes(3); 31 | expect(log.getLogger('deepl').debug).toHaveBeenNthCalledWith( 32 | 1, 33 | expect.stringContaining('Request details:'), 34 | ); 35 | expect(log.getLogger('deepl').debug).toHaveBeenNthCalledWith( 36 | 2, 37 | expect.stringContaining('Trace details:, xTraceId = '), 38 | ); 39 | expect(log.getLogger('deepl').debug).toHaveBeenNthCalledWith( 40 | 3, 41 | expect.stringContaining('Response details:, content = '), 42 | ); 43 | }); 44 | 45 | it('with rephrase text', async () => { 46 | const deeplClient = makeDeeplClient({}); 47 | const result = await deeplClient.rephraseText(exampleText.de, 'de'); 48 | 49 | expect(log.getLogger('deepl').debug).toHaveBeenCalledTimes(3); 50 | expect(log.getLogger('deepl').debug).toHaveBeenNthCalledWith( 51 | 1, 52 | expect.stringContaining('Request details:'), 53 | ); 54 | expect(log.getLogger('deepl').debug).toHaveBeenNthCalledWith( 55 | 2, 56 | expect.stringContaining('Trace details:, xTraceId = '), 57 | ); 58 | expect(log.getLogger('deepl').debug).toHaveBeenNthCalledWith( 59 | 3, 60 | expect.stringContaining('Response details:, content = '), 61 | ); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /tests/core.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 DeepL SE (https://www.deepl.com) 2 | // Use of this source code is governed by an MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | import * as deepl from 'deepl-node'; 6 | 7 | import fs from 'fs'; 8 | import os from 'os'; 9 | import path from 'path'; 10 | import { v4 as randomUUID } from 'uuid'; 11 | 12 | // Note: this constant cannot be exported immediately, because exports are locally undefined 13 | const internalExampleText: Record = { 14 | ar: 'شعاع البروتون', 15 | bg: 'протонен лъч', 16 | cs: 'protonový paprsek', 17 | da: 'protonstråle', 18 | de: 'Protonenstrahl', 19 | el: 'δέσμη πρωτονίων', 20 | en: 'proton beam', 21 | 'en-US': 'proton beam', 22 | 'en-GB': 'proton beam', 23 | es: 'haz de protones', 24 | et: 'prootonikiirgus', 25 | fi: 'protonisäde', 26 | fr: 'faisceau de protons', 27 | hu: 'protonnyaláb', 28 | id: 'berkas proton', 29 | it: 'fascio di protoni', 30 | ja: '陽子ビーム', 31 | ko: '양성자 빔', 32 | lt: 'protonų spindulys', 33 | lv: 'protonu staru kūlis', 34 | nb: 'protonstråle', 35 | nl: 'protonenbundel', 36 | pl: 'wiązka protonów', 37 | pt: 'feixe de prótons', 38 | 'pt-BR': 'feixe de prótons', 39 | 'pt-PT': 'feixe de prótons', 40 | ro: 'fascicul de protoni', 41 | ru: 'протонный луч', 42 | sk: 'protónový lúč', 43 | sl: 'protonski žarek', 44 | sv: 'protonstråle', 45 | tr: 'proton ışını', 46 | uk: 'протонний пучок', 47 | zh: '质子束', 48 | 'zh-HANS': '质子束', 49 | 'zh-HANT': '質子束', 50 | }; 51 | 52 | export const usingMockServer = process.env.DEEPL_MOCK_SERVER_PORT !== undefined; 53 | const usingMockProxyServer = 54 | usingMockServer && process.env.DEEPL_MOCK_PROXY_SERVER_PORT !== undefined; 55 | 56 | /** 57 | * Creates a random authKey for testing purposes. Only valid if using mock-server. 58 | */ 59 | function randomAuthKey(): string { 60 | if (!usingMockServer) throw new Error('A random authKey is only valid using mock-server.'); 61 | return randomUUID(); 62 | } 63 | 64 | function makeSessionName(): string { 65 | return `${expect.getState().currentTestName}/${randomUUID()}`; 66 | } 67 | 68 | export const exampleText = internalExampleText; 69 | export const exampleDocumentInput = exampleText.en; 70 | export const exampleDocumentOutput = exampleText.de; 71 | export const exampleLargeDocumentInput = (exampleText.en + '\n').repeat(1000); 72 | export const exampleLargeDocumentOutput = (exampleText.de + '\n').repeat(1000); 73 | 74 | /** 75 | * Creates temp directory, test files for a small and large .txt document, and an output path. 76 | */ 77 | export function tempFiles() { 78 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-node-test-')); 79 | 80 | const exampleDocument = `${tempDir}/example_document.txt`; 81 | const exampleLargeDocument = `${tempDir}/example_large_document.txt`; 82 | const outputDocumentPath = `${tempDir}/output_document.txt`; 83 | 84 | fs.writeFileSync(exampleDocument, exampleDocumentInput); 85 | fs.writeFileSync(exampleLargeDocument, exampleLargeDocumentInput); 86 | 87 | return [exampleDocument, exampleLargeDocument, outputDocumentPath, tempDir]; 88 | } 89 | 90 | export interface TestTranslatorOptions { 91 | authKey?: string; 92 | randomAuthKey?: boolean; 93 | maxRetries?: number; 94 | minTimeout?: number; 95 | proxy?: deepl.ProxyConfig; 96 | sendPlatformInfo?: boolean; 97 | appInfo?: deepl.AppInfo; 98 | 99 | mockServerNoResponseTimes?: number; 100 | mockServer429ResponseTimes?: number; 101 | mockServerInitCharacterLimit?: number; 102 | mockServerInitDocumentLimit?: number; 103 | mockServerInitTeamDocumentLimit?: number; 104 | mockServerDocFailureTimes?: number; 105 | mockServerDocQueueTime?: number; 106 | mockServerDocTranslateTime?: number; 107 | mockServerExpectProxy?: boolean; 108 | mockServerOptional?: boolean; 109 | } 110 | 111 | /** 112 | * Create a Translator object using given options for authKey, timeouts & retries, and mock-server 113 | * session settings. 114 | * @param options Options controlling Translator behaviour and mock-server sessions settings. 115 | */ 116 | export function makeTranslator(options?: TestTranslatorOptions): deepl.Translator { 117 | if (!usingMockServer && process.env.DEEPL_AUTH_KEY === undefined) { 118 | throw Error('DEEPL_AUTH_KEY environment variable must be defined unless using mock-server'); 119 | } 120 | 121 | const authKey = 122 | options?.authKey || 123 | (options?.randomAuthKey ? randomAuthKey() : process.env.DEEPL_AUTH_KEY || ''); 124 | 125 | const serverUrl = process.env.DEEPL_SERVER_URL; 126 | 127 | const sessionHeaders: Record = {}; 128 | if (options?.mockServerNoResponseTimes !== undefined) 129 | sessionHeaders['mock-server-session-no-response-count'] = String( 130 | options?.mockServerNoResponseTimes, 131 | ); 132 | if (options?.mockServer429ResponseTimes !== undefined) 133 | sessionHeaders['mock-server-session-429-count'] = String( 134 | options?.mockServer429ResponseTimes, 135 | ); 136 | if (options?.mockServerInitCharacterLimit !== undefined) 137 | sessionHeaders['mock-server-session-init-character-limit'] = String( 138 | options?.mockServerInitCharacterLimit, 139 | ); 140 | if (options?.mockServerInitDocumentLimit !== undefined) 141 | sessionHeaders['mock-server-session-init-document-limit'] = String( 142 | options?.mockServerInitDocumentLimit, 143 | ); 144 | if (options?.mockServerInitTeamDocumentLimit !== undefined) 145 | sessionHeaders['mock-server-session-init-team-document-limit'] = String( 146 | options?.mockServerInitTeamDocumentLimit, 147 | ); 148 | if (options?.mockServerDocFailureTimes !== undefined) 149 | sessionHeaders['mock-server-session-doc-failure'] = String( 150 | options?.mockServerDocFailureTimes, 151 | ); 152 | if (options?.mockServerDocQueueTime !== undefined) 153 | sessionHeaders['mock-server-session-doc-queue-time'] = String( 154 | options?.mockServerDocQueueTime, 155 | ); 156 | if (options?.mockServerDocTranslateTime !== undefined) 157 | sessionHeaders['mock-server-session-doc-translate-time'] = String( 158 | options?.mockServerDocTranslateTime, 159 | ); 160 | if (options?.mockServerExpectProxy !== undefined) 161 | sessionHeaders['mock-server-session-expect-proxy'] = options?.mockServerExpectProxy 162 | ? '1' 163 | : '0'; 164 | if (Object.entries(sessionHeaders).length !== 0) { 165 | if (!usingMockServer && !options?.mockServerOptional) 166 | throw new Error('Mock-server session is only used if using mock-server.'); 167 | sessionHeaders['mock-server-session'] = makeSessionName(); 168 | } 169 | 170 | return new deepl.Translator(authKey, { 171 | serverUrl: serverUrl, 172 | headers: sessionHeaders, 173 | minTimeout: options?.minTimeout, 174 | maxRetries: options?.maxRetries, 175 | proxy: options?.proxy, 176 | sendPlatformInfo: options?.sendPlatformInfo, 177 | appInfo: options?.appInfo, 178 | }); 179 | } 180 | 181 | /** 182 | * Create a Translator object using given options for authKey, timeouts & retries, and mock-server 183 | * session settings. 184 | * @param options Options controlling Translator behaviour and mock-server sessions settings. 185 | */ 186 | export function makeDeeplClient(options?: TestTranslatorOptions): deepl.DeepLClient { 187 | if (!usingMockServer && process.env.DEEPL_AUTH_KEY === undefined) { 188 | throw Error('DEEPL_AUTH_KEY environment variable must be defined unless using mock-server'); 189 | } 190 | 191 | const authKey = 192 | options?.authKey || 193 | (options?.randomAuthKey ? randomAuthKey() : process.env.DEEPL_AUTH_KEY || ''); 194 | 195 | const serverUrl = process.env.DEEPL_SERVER_URL; 196 | 197 | const sessionHeaders: Record = {}; 198 | if (options?.mockServerNoResponseTimes !== undefined) 199 | sessionHeaders['mock-server-session-no-response-count'] = String( 200 | options?.mockServerNoResponseTimes, 201 | ); 202 | if (options?.mockServer429ResponseTimes !== undefined) 203 | sessionHeaders['mock-server-session-429-count'] = String( 204 | options?.mockServer429ResponseTimes, 205 | ); 206 | if (options?.mockServerInitCharacterLimit !== undefined) 207 | sessionHeaders['mock-server-session-init-character-limit'] = String( 208 | options?.mockServerInitCharacterLimit, 209 | ); 210 | if (options?.mockServerInitDocumentLimit !== undefined) 211 | sessionHeaders['mock-server-session-init-document-limit'] = String( 212 | options?.mockServerInitDocumentLimit, 213 | ); 214 | if (options?.mockServerInitTeamDocumentLimit !== undefined) 215 | sessionHeaders['mock-server-session-init-team-document-limit'] = String( 216 | options?.mockServerInitTeamDocumentLimit, 217 | ); 218 | if (options?.mockServerDocFailureTimes !== undefined) 219 | sessionHeaders['mock-server-session-doc-failure'] = String( 220 | options?.mockServerDocFailureTimes, 221 | ); 222 | if (options?.mockServerDocQueueTime !== undefined) 223 | sessionHeaders['mock-server-session-doc-queue-time'] = String( 224 | options?.mockServerDocQueueTime, 225 | ); 226 | if (options?.mockServerDocTranslateTime !== undefined) 227 | sessionHeaders['mock-server-session-doc-translate-time'] = String( 228 | options?.mockServerDocTranslateTime, 229 | ); 230 | if (options?.mockServerExpectProxy !== undefined) 231 | sessionHeaders['mock-server-session-expect-proxy'] = options?.mockServerExpectProxy 232 | ? '1' 233 | : '0'; 234 | if (Object.entries(sessionHeaders).length !== 0) { 235 | if (!usingMockServer && !options?.mockServerOptional) 236 | throw new Error('Mock-server session is only used if using mock-server.'); 237 | sessionHeaders['mock-server-session'] = makeSessionName(); 238 | } 239 | 240 | return new deepl.DeepLClient(authKey, { 241 | serverUrl: serverUrl, 242 | headers: sessionHeaders, 243 | minTimeout: options?.minTimeout, 244 | maxRetries: options?.maxRetries, 245 | proxy: options?.proxy, 246 | sendPlatformInfo: options?.sendPlatformInfo, 247 | appInfo: options?.appInfo, 248 | }); 249 | } 250 | 251 | // Use instead of it(...) for tests that require a mock-server 252 | export const withMockServer = usingMockServer ? it : it.skip; 253 | // Use instead of it(...) for tests that require a mock-server with proxy 254 | export const withMockProxyServer = usingMockProxyServer ? it : it.skip; 255 | // Use instead of it(...) for tests that cannot run using mock-server 256 | export const withRealServer = usingMockServer ? it.skip : it; 257 | 258 | const proxyUrlString = process.env.DEEPL_PROXY_URL; 259 | const proxyUrl = proxyUrlString ? new URL(proxyUrlString) : undefined; 260 | const proxyConfigHost = proxyUrl ? proxyUrl.hostname : ''; 261 | const proxyConfigPort = parseInt(process.env.DEEPL_MOCK_PROXY_SERVER_PORT || ''); 262 | 263 | export const proxyConfig: deepl.ProxyConfig = { host: proxyConfigHost, port: proxyConfigPort }; 264 | 265 | // Wrap setTimeout() with Promise 266 | export const timeout = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 267 | 268 | // Mocked document translation tests need slightly higher timeout limit 269 | export const documentTranslationTestTimeout = 10000; 270 | 271 | // E2E tests need a significantly higher timeout limit 272 | export const testTimeout = 60000; 273 | 274 | // Base URL for mocking out HTTP client 275 | export const urlToMockRegexp = 276 | /(https?:\/\/api.*\.deepl\.com)|(deepl-mock:\d+)|(https?:\/\/localhost:\d+)/; 277 | 278 | // For Document Minification 279 | export const testFilePaths = { 280 | pptx: 'tests/resources/example_presentation_template.pptx', 281 | docx: 'tests/resources/example_document_template.docx', 282 | zip: 'tests/resources/example_zip_template.zip', 283 | }; 284 | 285 | module.exports = { 286 | testFilePaths, 287 | exampleText, 288 | exampleDocumentInput, 289 | exampleDocumentOutput, 290 | exampleLargeDocumentInput, 291 | exampleLargeDocumentOutput, 292 | tempFiles, 293 | withMockServer, 294 | withMockProxyServer, 295 | withRealServer, 296 | makeTranslator, 297 | makeDeeplClient, 298 | documentTranslationTestTimeout, 299 | testTimeout, 300 | timeout, 301 | urlToMockRegexp, 302 | proxyConfig, 303 | usingMockServer, 304 | }; 305 | -------------------------------------------------------------------------------- /tests/documentMinification/helperMethods.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import * as os from 'os'; 4 | import { DocumentMinifier } from '../../src/documentMinifier'; 5 | import AdmZip from 'adm-zip'; 6 | import { DocumentMinificationError } from '../../src'; 7 | import { FsHelper } from '../../src/fsHelper'; 8 | import { v4 as uuidv4 } from 'uuid'; 9 | import mock from 'mock-fs'; 10 | 11 | describe('DocumentMinifier helperMethods', () => { 12 | beforeEach(() => { 13 | // Use in-memory file system for fs 14 | mock(); 15 | }); 16 | 17 | afterEach(() => { 18 | mock.restore(); 19 | }); 20 | 21 | describe('createTemporaryDirectory', () => { 22 | it('should create a temporary directory in system temp location', () => { 23 | const minifier = new DocumentMinifier(); 24 | 25 | const tempDir = (minifier as any)._tempDir; 26 | 27 | expect(fs.existsSync(tempDir)).toBe(true); 28 | expect(tempDir.startsWith(os.tmpdir())).toBe(true); 29 | expect(tempDir).toMatch(/document_minification_[0-9a-f-]+$/); 30 | 31 | FsHelper.removeSyncRecursive(tempDir); 32 | }); 33 | 34 | it('should use provided temp directory when specified', () => { 35 | const customTempDir = path.join(os.tmpdir(), 'custom_temp_dir' + uuidv4()); 36 | fs.mkdirSync(customTempDir); 37 | 38 | const minifier = new DocumentMinifier(customTempDir); 39 | const tempDir = (minifier as any)._tempDir; 40 | 41 | expect(tempDir).toBe(customTempDir); 42 | 43 | FsHelper.removeSyncRecursive(tempDir); 44 | }); 45 | }); 46 | 47 | describe('extractZipToDirectory', () => { 48 | let tempDir: string; 49 | let documentMinifier: DocumentMinifier; 50 | 51 | beforeEach(() => { 52 | tempDir = fs.mkdtempSync('test-doc-minifier-'); 53 | documentMinifier = new DocumentMinifier(tempDir); 54 | }); 55 | 56 | afterEach(() => { 57 | if (fs.existsSync(tempDir)) { 58 | FsHelper.removeSyncRecursive(tempDir); 59 | } 60 | }); 61 | 62 | it('should extract all files from zip to the extraction directory', () => { 63 | const extractionDir = path.join(tempDir, 'extraction'); 64 | const zipPath = path.join(tempDir, 'test.zip'); 65 | 66 | const zip = new AdmZip(); 67 | zip.addFile('test.txt', Buffer.from('test content')); 68 | zip.addFile('subfolder/nested.txt', Buffer.from('nested content')); 69 | zip.writeZip(zipPath); 70 | 71 | (documentMinifier as any).extractZipToDirectory(zipPath, extractionDir); 72 | 73 | expect(fs.existsSync(path.join(extractionDir, 'test.txt'))).toBe(true); 74 | expect(fs.existsSync(path.join(extractionDir, 'subfolder/nested.txt'))).toBe(true); 75 | expect(fs.readFileSync(path.join(extractionDir, 'test.txt'), 'utf8')).toBe( 76 | 'test content', 77 | ); 78 | expect(fs.readFileSync(path.join(extractionDir, 'subfolder/nested.txt'), 'utf8')).toBe( 79 | 'nested content', 80 | ); 81 | }); 82 | 83 | it('should overwrite existing files in extraction directory', () => { 84 | const extractionDir = path.join(tempDir, 'extraction'); 85 | fs.mkdirSync(extractionDir); 86 | fs.writeFileSync(path.join(extractionDir, 'test.txt'), 'old content'); 87 | 88 | const zipPath = path.join(tempDir, 'test.zip'); 89 | const zip = new AdmZip(); 90 | zip.addFile('test.txt', Buffer.from('new content')); 91 | zip.writeZip(zipPath); 92 | 93 | (documentMinifier as any).extractZipToDirectory(zipPath, extractionDir); 94 | 95 | expect(fs.readFileSync(path.join(extractionDir, 'test.txt'), 'utf8')).toBe( 96 | 'new content', 97 | ); 98 | }); 99 | 100 | it('should throw error if zip file does not exist', () => { 101 | const nonExistentZip = path.join(tempDir, 'nonexistent.zip'); 102 | const extractionDir = path.join(tempDir, 'extraction'); 103 | 104 | expect(() => { 105 | (documentMinifier as any).extractZipToDirectory(nonExistentZip, extractionDir); 106 | }).toThrow(Error); 107 | }); 108 | }); 109 | 110 | describe('createZipFromDirectory', () => { 111 | let tempDir: string; 112 | let documentMinifier: DocumentMinifier; 113 | 114 | beforeEach(() => { 115 | tempDir = fs.mkdtempSync('test-doc-minifier-'); 116 | documentMinifier = new DocumentMinifier(tempDir); 117 | }); 118 | 119 | afterEach(() => { 120 | if (fs.existsSync(tempDir)) { 121 | FsHelper.removeSyncRecursive(tempDir); 122 | } 123 | }); 124 | 125 | it('should create a zip file containing all files from source directory', () => { 126 | const sourceDir = path.join(tempDir, 'source'); 127 | const outputPath = path.join(tempDir, 'output.zip'); 128 | fs.mkdirSync(sourceDir); 129 | fs.writeFileSync(path.join(sourceDir, 'test.txt'), 'test content'); 130 | fs.writeFileSync(path.join(sourceDir, 'test2.txt'), 'test content 2'); 131 | 132 | (documentMinifier as any).createZipFromDirectory(sourceDir, outputPath); 133 | 134 | expect(fs.existsSync(outputPath)).toBe(true); 135 | const zip = new AdmZip(outputPath); 136 | const entries = zip.getEntries(); 137 | expect(entries).toHaveLength(2); 138 | expect(entries.map((e) => e.entryName).sort()).toEqual(['test.txt', 'test2.txt']); 139 | expect(zip.readAsText('test.txt')).toBe('test content'); 140 | expect(zip.readAsText('test2.txt')).toBe('test content 2'); 141 | }); 142 | 143 | it('should throw error if source directory does not exist', () => { 144 | const nonExistentDir = path.join(tempDir, 'nonexistent'); 145 | const outputPath = path.join(tempDir, 'output.zip'); 146 | 147 | expect(() => { 148 | (documentMinifier as any).createZipFromDirectory(nonExistentDir, outputPath); 149 | }).toThrow(Error); 150 | }); 151 | }); 152 | 153 | describe('exportMediaToMediaDirAndReplace', () => { 154 | let tempDir: string; 155 | let documentMinifier: DocumentMinifier; 156 | 157 | beforeEach(() => { 158 | tempDir = fs.mkdtempSync('test-doc-minifier-'); 159 | documentMinifier = new DocumentMinifier(tempDir); 160 | }); 161 | 162 | afterEach(() => { 163 | if (fs.existsSync(tempDir)) { 164 | FsHelper.removeSyncRecursive(tempDir); 165 | } 166 | }); 167 | 168 | it('should move media files to media directory as placeholders', () => { 169 | const inputDir = path.join(tempDir, 'input'); 170 | fs.mkdirSync(inputDir); 171 | fs.writeFileSync(path.join(inputDir, 'image.png'), 'image content'); 172 | fs.writeFileSync(path.join(inputDir, 'video.mp4'), 'video content'); 173 | fs.writeFileSync(path.join(inputDir, 'audio.mp3'), 'audio content'); 174 | 175 | const mediaDir = path.join(tempDir, 'media'); 176 | fs.mkdirSync(mediaDir); 177 | 178 | (documentMinifier as any).exportMediaToMediaDirAndReplace(inputDir, mediaDir); 179 | 180 | expect(fs.existsSync(path.join(mediaDir, 'image.png'))).toBe(true); 181 | expect(fs.readFileSync(path.join(mediaDir, 'image.png'), 'utf8')).toBe('image content'); 182 | expect(fs.existsSync(path.join(mediaDir, 'video.mp4'))).toBe(true); 183 | expect(fs.readFileSync(path.join(mediaDir, 'video.mp4'), 'utf8')).toBe('video content'); 184 | expect(fs.existsSync(path.join(mediaDir, 'audio.mp3'))).toBe(true); 185 | expect(fs.readFileSync(path.join(mediaDir, 'audio.mp3'), 'utf8')).toBe('audio content'); 186 | }); 187 | 188 | it('should move media files from subdirectories to media directory as placeholders', () => { 189 | const inputDir = path.join(tempDir, 'input'); 190 | fs.mkdirSync(inputDir); 191 | fs.mkdirSync(path.join(inputDir, 'subdir1')); 192 | fs.mkdirSync(path.join(inputDir, 'subdir2')); 193 | fs.writeFileSync(path.join(inputDir, 'subdir1/image1.png'), 'image content 1'); 194 | fs.writeFileSync(path.join(inputDir, 'subdir2/video1.mp4'), 'video content 1'); 195 | 196 | const mediaDir = path.join(tempDir, 'media'); 197 | fs.mkdirSync(mediaDir); 198 | 199 | (documentMinifier as any).exportMediaToMediaDirAndReplace(inputDir, mediaDir); 200 | 201 | expect(fs.existsSync(path.join(mediaDir, 'subdir1', 'image1.png'))).toBe(true); 202 | expect(fs.readFileSync(path.join(mediaDir, 'subdir1', 'image1.png'), 'utf8')).toBe( 203 | 'image content 1', 204 | ); 205 | expect(fs.existsSync(path.join(mediaDir, 'subdir2', 'video1.mp4'))).toBe(true); 206 | expect(fs.readFileSync(path.join(mediaDir, 'subdir2', 'video1.mp4'), 'utf8')).toBe( 207 | 'video content 1', 208 | ); 209 | }); 210 | 211 | it('should throw DocumentMinificationError on file operation error', () => { 212 | const inputDir = path.join(tempDir, 'input'); 213 | fs.mkdirSync(inputDir); 214 | fs.writeFileSync(path.join(inputDir, 'image.png'), 'image content'); 215 | 216 | const mediaDir = path.join(tempDir, 'media'); 217 | fs.mkdirSync(mediaDir); 218 | fs.chmodSync(mediaDir, 0o444); // read-only directory 219 | 220 | expect(() => { 221 | (documentMinifier as any).exportMediaToMediaDirAndReplace(inputDir, mediaDir); 222 | }).toThrow(DocumentMinificationError); 223 | }); 224 | }); 225 | 226 | describe('replaceMediaInDir', () => { 227 | let documentMinifier: DocumentMinifier; 228 | let tempDir: string; 229 | let inputDirectory: string; 230 | let mediaDirectory: string; 231 | 232 | beforeEach(() => { 233 | tempDir = fs.mkdtempSync('test-doc-minifier-'); 234 | documentMinifier = new DocumentMinifier(tempDir); 235 | 236 | inputDirectory = path.join(tempDir, 'input'); 237 | mediaDirectory = path.join(tempDir, 'media'); 238 | fs.mkdirSync(inputDirectory, { recursive: true }); 239 | fs.mkdirSync(mediaDirectory, { recursive: true }); 240 | }); 241 | 242 | afterEach(() => { 243 | FsHelper.removeSyncRecursive(tempDir); 244 | }); 245 | 246 | test('should move media files back to input directory and create directories if they do not exist', () => { 247 | const mediaFilePath = path.join(mediaDirectory, 'image.png'); 248 | fs.mkdirSync(path.dirname(mediaFilePath), { recursive: true }); 249 | fs.writeFileSync(mediaFilePath, 'image content'); 250 | 251 | (documentMinifier as any).replaceMediaInDir(inputDirectory, mediaDirectory); 252 | 253 | const inputFilePath = path.join(inputDirectory, 'image.png'); 254 | expect(fs.existsSync(inputFilePath)).toBe(true); 255 | expect(fs.readFileSync(inputFilePath, 'utf-8')).toBe('image content'); 256 | }); 257 | 258 | test('should move media files from subdirectories back to input directory', () => { 259 | const subDirMediaPath = path.join(mediaDirectory, 'subdir1', 'image1.png'); 260 | fs.mkdirSync(path.dirname(subDirMediaPath), { recursive: true }); 261 | fs.writeFileSync(subDirMediaPath, 'image content 1'); 262 | 263 | const subDirInputPath = path.join(inputDirectory, 'subdir1'); 264 | fs.mkdirSync(subDirInputPath, { recursive: true }); 265 | 266 | (documentMinifier as any).replaceMediaInDir(inputDirectory, mediaDirectory); 267 | 268 | const inputFilePath = path.join(subDirInputPath, 'image1.png'); 269 | expect(fs.existsSync(inputFilePath)).toBe(true); 270 | expect(fs.readFileSync(inputFilePath, 'utf-8')).toBe('image content 1'); 271 | }); 272 | }); 273 | 274 | describe('canMinifyFile', () => { 275 | it('should return true for supported file types', () => { 276 | expect(DocumentMinifier.canMinifyFile('test.pptx')).toBe(true); 277 | expect(DocumentMinifier.canMinifyFile('test.docx')).toBe(true); 278 | }); 279 | 280 | it('should return false for unsupported file types', () => { 281 | expect(DocumentMinifier.canMinifyFile('test.pdf')).toBe(false); 282 | expect(DocumentMinifier.canMinifyFile('test.txt')).toBe(false); 283 | expect(DocumentMinifier.canMinifyFile('')).toBe(false); 284 | }); 285 | }); 286 | }); 287 | -------------------------------------------------------------------------------- /tests/documentMinification/lifecycle.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import { FsHelper } from '../../src/fsHelper'; 4 | import { makeTranslator, testFilePaths, withRealServer } from '../core'; 5 | import { DocumentTranslationError, Translator } from '../../src'; 6 | import { createMinifiableTestDocument } from './testHelpers'; 7 | import mock from 'mock-fs'; 8 | 9 | jest.setTimeout(100000); 10 | 11 | describe('minification lifecycle with translate', () => { 12 | beforeEach(() => { 13 | // Use in-memory file system for fs, but load real folder from tests/resources 14 | mock({ 15 | 'tests/resources': mock.load('tests/resources'), 16 | }); 17 | }); 18 | 19 | afterEach(() => { 20 | mock.restore(); 21 | }); 22 | 23 | let tempDir: string; 24 | 25 | beforeEach(() => { 26 | tempDir = fs.mkdtempSync('test-doc-minifier-'); 27 | }); 28 | 29 | afterEach(() => { 30 | if (fs.existsSync(tempDir)) { 31 | FsHelper.removeSyncRecursive(tempDir); 32 | } 33 | }); 34 | 35 | withRealServer('should minify, translate, and deminify', async () => { 36 | const originalFile = testFilePaths.pptx; 37 | const translator = makeTranslator() as Translator; 38 | 39 | const minifiableFilePath = createMinifiableTestDocument( 40 | originalFile, 41 | `${tempDir}/test-document-zip-content`, 42 | tempDir, 43 | ); 44 | expect(fs.statSync(minifiableFilePath).size).toBeGreaterThan(30000000); 45 | 46 | const outputFilePath = `${tempDir}/translatedAndDeminified${path.extname(originalFile)}`; 47 | 48 | await translator.translateDocument(minifiableFilePath, outputFilePath, 'en', 'de', { 49 | enableDocumentMinification: true, 50 | }); 51 | 52 | expect(fs.existsSync(outputFilePath)).toBe(true); 53 | expect(fs.statSync(outputFilePath).size).toEqual(fs.statSync(minifiableFilePath).size); 54 | // If the output exists, the input document must have been minified as TranslateDocumentAsync 55 | // will not succeed for files over 30 MB 56 | expect(fs.statSync(outputFilePath).size).toBeGreaterThan(30000000); 57 | }); 58 | 59 | withRealServer( 60 | 'should not minify when not specified and should error when translating too large of a document', 61 | async () => { 62 | const originalFile = testFilePaths.pptx; 63 | const translator = makeTranslator() as Translator; 64 | 65 | const minifiableFilePath = createMinifiableTestDocument( 66 | originalFile, 67 | `${tempDir}/test-document-zip-content`, 68 | tempDir, 69 | ); 70 | expect(fs.statSync(minifiableFilePath).size).toBeGreaterThan(30000000); 71 | 72 | const outputFilePath = `${tempDir}/translatedAndDeminified${path.extname( 73 | originalFile, 74 | )}`; 75 | 76 | await expect( 77 | translator.translateDocument( 78 | minifiableFilePath, 79 | outputFilePath, 80 | 'en', 81 | 'de', 82 | // do not include additional options 83 | ), 84 | ).rejects.toThrowError(DocumentTranslationError); 85 | }, 86 | ); 87 | }); 88 | -------------------------------------------------------------------------------- /tests/documentMinification/minifyAndDeminify.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import { DocumentMinifier } from '../../src/documentMinifier'; 4 | import { FsHelper } from '../../src/fsHelper'; 5 | import { testFilePaths } from '../core'; 6 | import { createMinifiableTestDocument } from './testHelpers'; 7 | import mock from 'mock-fs'; 8 | 9 | describe('DocumentMinifier', () => { 10 | beforeEach(() => { 11 | // Use in-memory file system for fs, but load real folder from tests/resources 12 | mock({ 13 | 'tests/resources': mock.load('tests/resources'), 14 | }); 15 | }); 16 | 17 | afterEach(() => { 18 | mock.restore(); 19 | }); 20 | 21 | describe('minifyDocument', () => { 22 | let tempDir: string; 23 | let documentMinifier: DocumentMinifier; 24 | 25 | beforeEach(() => { 26 | tempDir = fs.mkdtempSync('test-doc-minifier-'); 27 | documentMinifier = new DocumentMinifier(tempDir); 28 | }); 29 | 30 | afterEach(() => { 31 | FsHelper.removeSyncRecursive(tempDir); 32 | }); 33 | 34 | it.each([testFilePaths.pptx, testFilePaths.docx])( 35 | 'should make the file size smaller for %s', 36 | (testFilePath) => { 37 | documentMinifier = new DocumentMinifier(tempDir); 38 | 39 | const tempZipContentDirectory = path.join(tempDir, 'test-document-zip-content'); 40 | fs.mkdirSync(tempZipContentDirectory); 41 | const testDocumentPath = createMinifiableTestDocument( 42 | testFilePath, 43 | tempZipContentDirectory, 44 | tempDir, 45 | ); 46 | FsHelper.removeSyncRecursive(tempZipContentDirectory); 47 | 48 | const minifiedDocumentPath = documentMinifier.minifyDocument( 49 | testDocumentPath, 50 | false, 51 | ); 52 | 53 | const minifiedFileSize = fs.statSync(minifiedDocumentPath).size; 54 | expect(minifiedFileSize).toBeLessThan(fs.statSync(testDocumentPath).size); 55 | expect(minifiedFileSize).toBeGreaterThanOrEqual(100); 56 | expect(minifiedFileSize).toBeLessThanOrEqual(50000); 57 | }, 58 | ); 59 | 60 | it.each([testFilePaths.pptx, testFilePaths.docx])( 61 | 'should handle correctly when cleanup=true for %s', 62 | (testFilePath) => { 63 | const minifiedDocumentPath = documentMinifier.minifyDocument(testFilePath, true); 64 | 65 | expect(fs.existsSync(documentMinifier.getExtractedDocDirectory())).toBe(false); 66 | expect(fs.existsSync(documentMinifier.getOriginalMediaDirectory())).toBe(true); 67 | expect(fs.existsSync(minifiedDocumentPath)).toBe(true); 68 | }, 69 | ); 70 | 71 | it.each([testFilePaths.pptx, testFilePaths.docx])( 72 | 'should handle cleanup=false for %s', 73 | (testFilePath) => { 74 | const minifiedDocumentPath = documentMinifier.minifyDocument(testFilePath, false); 75 | 76 | expect(fs.existsSync(documentMinifier.getExtractedDocDirectory())).toBe(true); 77 | expect(fs.existsSync(documentMinifier.getOriginalMediaDirectory())).toBe(true); 78 | expect(fs.existsSync(minifiedDocumentPath)).toBe(true); 79 | }, 80 | ); 81 | 82 | it('should throw an error for a file that cannot be extracted', () => { 83 | const unsupportedFilePath = 'unsupported_file.txt'; 84 | 85 | jest.spyOn(documentMinifier as any, 'extractZipToDirectory').mockImplementationOnce( 86 | () => { 87 | throw new Error('Custom error message'); 88 | }, 89 | ); 90 | 91 | expect(() => { 92 | documentMinifier.minifyDocument(unsupportedFilePath, false); 93 | }).toThrowError(/Error when extracting document/); 94 | }); 95 | 96 | it('should throw an error for a file that cannot be extracted', () => { 97 | const testFilePath = testFilePaths.docx; 98 | 99 | jest.spyOn(documentMinifier as any, 'createZipFromDirectory').mockImplementationOnce( 100 | () => { 101 | throw new Error('Custom error message'); 102 | }, 103 | ); 104 | 105 | expect(() => { 106 | documentMinifier.minifyDocument(testFilePath, false); 107 | }).toThrowError(/Failed creating a zip file/); 108 | }); 109 | 110 | it('should throw an error if cleanup fails', () => { 111 | const testFilePath = testFilePaths.docx; 112 | jest.spyOn(FsHelper, 'removeSyncRecursive').mockImplementationOnce(() => { 113 | throw new Error('Custom error message'); 114 | }); 115 | 116 | expect(() => { 117 | documentMinifier.minifyDocument(testFilePath, true); 118 | }).toThrowError(/Failed to delete directory/); 119 | }); 120 | 121 | it('should throw an error if the file is over the limit', () => { 122 | const testFilePath = testFilePaths.docx; 123 | 124 | const previousLimit = (documentMinifier as any).MINIFIED_DOC_SIZE_LIMIT_WARNING; 125 | (DocumentMinifier as any).MINIFIED_DOC_SIZE_LIMIT_WARNING = 1; 126 | 127 | const consoleErrorSpy = jest.spyOn(console, 'error'); 128 | documentMinifier.minifyDocument(testFilePath, false); 129 | 130 | expect(consoleErrorSpy).toHaveBeenCalled(); 131 | expect(consoleErrorSpy).toHaveBeenCalledWith( 132 | 'The input file could not be minified below 5 MB, likely a media type is missing. ' + 133 | 'This might cause the translation to fail.', 134 | ); 135 | 136 | (DocumentMinifier as any).MINIFIED_DOC_SIZE_LIMIT_WARNING = previousLimit; 137 | }); 138 | }); 139 | 140 | describe('deminifyDocument', () => { 141 | let tempDir: string; 142 | let documentMinifier: DocumentMinifier; 143 | 144 | beforeEach(() => { 145 | tempDir = fs.mkdtempSync('test-doc-minifier-'); 146 | documentMinifier = new DocumentMinifier(tempDir); 147 | }); 148 | 149 | afterEach(() => { 150 | if (fs.existsSync(tempDir)) { 151 | FsHelper.removeSyncRecursive(tempDir); 152 | } 153 | }); 154 | 155 | it.each([testFilePaths.docx, testFilePaths.pptx])( 156 | 'should deminify a zipped file and be approximately the same as the original file %s', 157 | (originalFile) => { 158 | const inputFilePath = testFilePaths.zip; 159 | const outputFilePath = `${tempDir}/deminified${path.extname(originalFile)}`; 160 | 161 | documentMinifier.minifyDocument(originalFile); 162 | documentMinifier.deminifyDocument(inputFilePath, outputFilePath); 163 | 164 | const originalFileSize = fs.statSync(originalFile).size; 165 | const outputFileSize = fs.statSync(outputFilePath).size; 166 | 167 | expect(Math.abs(originalFileSize - outputFileSize)).toBeLessThanOrEqual(1000); 168 | }, 169 | ); 170 | 171 | it('should handle correctly when cleanup=false', () => { 172 | const originalFile = testFilePaths.docx; 173 | const inputFilePath = testFilePaths.zip; 174 | const outputFilePath = `${tempDir}/deminified.docx`; 175 | 176 | documentMinifier.minifyDocument(originalFile); 177 | documentMinifier.deminifyDocument(inputFilePath, outputFilePath); 178 | 179 | expect(fs.existsSync(tempDir)).toBe(true); 180 | }); 181 | 182 | it('should handle correctly when cleanup=true', () => { 183 | const originalFile = testFilePaths.docx; 184 | const inputFilePath = testFilePaths.zip; 185 | const outputFilePath = `${tempDir}/deminified.docx`; 186 | 187 | documentMinifier.minifyDocument(originalFile); 188 | documentMinifier.deminifyDocument(inputFilePath, outputFilePath, true); 189 | 190 | expect(fs.existsSync(tempDir)).toBe(false); 191 | }); 192 | 193 | it('should throw an error if unzipping fails', () => { 194 | const inputFilePath = testFilePaths.zip; 195 | const outputFilePath = `${tempDir}/deminified.docx`; 196 | 197 | jest.spyOn(documentMinifier as any, 'extractZipToDirectory').mockImplementationOnce( 198 | () => { 199 | throw new Error('Custom error message'); 200 | }, 201 | ); 202 | 203 | expect(() => { 204 | documentMinifier.deminifyDocument(inputFilePath, outputFilePath); 205 | }).toThrowError(/Failed to extract/); 206 | }); 207 | 208 | it('should replace existing outputFilePath if it already exists', () => { 209 | documentMinifier.minifyDocument(testFilePaths.docx); 210 | 211 | const inputFilePath = testFilePaths.zip; 212 | const outputFilePath = `${tempDir}/deminified.docx`; 213 | 214 | fs.writeFileSync(outputFilePath, 'pre-existing content'); 215 | 216 | documentMinifier.deminifyDocument(inputFilePath, outputFilePath); 217 | 218 | expect(fs.readFileSync(outputFilePath, 'utf-8')).not.toBe('pre-existing content'); 219 | }); 220 | 221 | it('should throw an error if zipping failed', () => { 222 | documentMinifier.minifyDocument(testFilePaths.docx); 223 | 224 | const inputFilePath = testFilePaths.zip; 225 | const outputFilePath = `${tempDir}/deminified.docx`; 226 | 227 | jest.spyOn(documentMinifier as any, 'createZipFromDirectory').mockImplementationOnce( 228 | () => { 229 | throw new Error('Custom error message'); 230 | }, 231 | ); 232 | 233 | expect(() => { 234 | documentMinifier.deminifyDocument(inputFilePath, outputFilePath); 235 | }).toThrowError(/Failed creating a zip file/); 236 | }); 237 | }); 238 | }); 239 | -------------------------------------------------------------------------------- /tests/documentMinification/testHelpers.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import AdmZip from 'adm-zip'; 4 | 5 | const MINIFIABLE_FILE_SIZE = 90000000; 6 | 7 | export const createMinifiableTestDocument = ( 8 | testFilePath: string, 9 | tempZipContentDirectory: string, 10 | outputDirectory: string, 11 | ): string => { 12 | if (!fs.existsSync(testFilePath)) { 13 | throw new Error(`Test file does not exist: ${testFilePath}`); 14 | } 15 | const zipExtractor = new AdmZip(testFilePath); 16 | zipExtractor.extractAllTo(tempZipContentDirectory); 17 | 18 | // Create a placeholder file of size 90 MB 19 | const characters = 20 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ~!@#$%^&*()_+=-<,>.?:'; 21 | const createText = Array.from( 22 | { length: MINIFIABLE_FILE_SIZE }, 23 | () => characters[Math.floor(Math.random() * characters.length)], 24 | ).join(''); 25 | fs.writeFileSync(path.join(tempZipContentDirectory, 'placeholder_image.png'), createText); 26 | 27 | const fileName = path.basename(testFilePath); 28 | const outputFilePath = path.join(outputDirectory, fileName); 29 | const zipCombiner = new AdmZip(); 30 | zipCombiner.addLocalFolder(tempZipContentDirectory); 31 | zipCombiner.writeZip(outputFilePath); 32 | return outputFilePath; 33 | }; 34 | -------------------------------------------------------------------------------- /tests/fsHelper/withFsMocks.test.ts: -------------------------------------------------------------------------------- 1 | import { FsHelper } from '../../src/fsHelper'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | jest.mock('fs'); 5 | 6 | describe('FsHelper (with mocks)', () => { 7 | describe('readdirSyncRecursive', () => { 8 | beforeEach(() => { 9 | jest.resetAllMocks(); 10 | }); 11 | 12 | it('should throw an error if the directory does not exist', () => { 13 | jest.spyOn(fs, 'existsSync').mockReturnValue(false); 14 | expect(() => FsHelper.readdirSyncRecursive('nonexistent/')).toThrowError( 15 | 'Error: no such file or directory, nonexistent/', 16 | ); 17 | }); 18 | 19 | it('should throw an error if the file does not exist', () => { 20 | (fs.existsSync as jest.Mock).mockReturnValue(true); 21 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => false }); 22 | 23 | expect(() => FsHelper.readdirSyncRecursive('file.txt')).toThrowError( 24 | 'Error: not a directory, file.txt', 25 | ); 26 | }); 27 | 28 | it('should return an empty array if the directory is empty', () => { 29 | (fs.existsSync as jest.Mock).mockReturnValue(true); 30 | (fs.readdirSync as jest.Mock).mockReturnValue([]); 31 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => true }); 32 | 33 | expect(FsHelper.readdirSyncRecursive('emptyDir')).toEqual([]); 34 | }); 35 | 36 | it('should return an array of files and subdirectories', () => { 37 | (fs.existsSync as jest.Mock).mockReturnValue(true); 38 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => true }); 39 | (fs.readdirSync as jest.Mock).mockReturnValueOnce([ 40 | 'file1.txt', 41 | 'file2.txt', 42 | 'emptyDir', 43 | 'subDir', 44 | ]); 45 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => false }); 46 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => false }); 47 | 48 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => true }); 49 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => true }); 50 | (fs.readdirSync as jest.Mock).mockReturnValueOnce([]); 51 | 52 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => true }); 53 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => true }); 54 | (fs.readdirSync as jest.Mock).mockReturnValueOnce(['subfile1.txt', 'subfile2.txt']); 55 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => false }); 56 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => false }); 57 | 58 | expect(FsHelper.readdirSyncRecursive('dir')).toEqual([ 59 | 'file1.txt', 60 | 'file2.txt', 61 | 'emptyDir', 62 | 'subDir', 63 | 'subDir/subfile1.txt', 64 | 'subDir/subfile2.txt', 65 | ]); 66 | }); 67 | }); 68 | 69 | describe('removeSyncRecursive', () => { 70 | beforeEach(() => { 71 | jest.resetAllMocks(); 72 | }); 73 | 74 | it('should throw an error if the file does not exist', () => { 75 | (fs.existsSync as jest.Mock).mockReturnValue(false); 76 | expect(() => FsHelper.removeSyncRecursive('nonexistent.txt')).toThrowError( 77 | 'Error: no such file or directory, nonexistent.txt', 78 | ); 79 | }); 80 | 81 | it('should remove a file', () => { 82 | (fs.existsSync as jest.Mock).mockReturnValue(true); 83 | (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); 84 | 85 | const unlinkSyncSpy = jest.spyOn(fs, 'unlinkSync'); 86 | 87 | FsHelper.removeSyncRecursive('file.txt'); 88 | 89 | expect(unlinkSyncSpy).toHaveBeenCalledWith('file.txt'); 90 | }); 91 | 92 | it('should remove a directory with one file in it', () => { 93 | (fs.existsSync as jest.Mock).mockReturnValue(true); 94 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => true }); 95 | (fs.readdirSync as jest.Mock).mockReturnValueOnce(['file.txt']); 96 | 97 | (fs.readdirSync as jest.Mock).mockReturnValueOnce([]); 98 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => false }); 99 | 100 | const unlinkSyncSpy = jest.spyOn(fs, 'unlinkSync'); 101 | const rmdirSyncSpy = jest.spyOn(fs, 'rmdirSync'); 102 | 103 | FsHelper.removeSyncRecursive('dir'); 104 | 105 | expect(unlinkSyncSpy).toHaveBeenNthCalledWith(1, 'dir/file.txt'); 106 | expect(rmdirSyncSpy).toHaveBeenNthCalledWith(1, 'dir'); 107 | }); 108 | 109 | it('should remove a directory and its contents, including subdirectories', () => { 110 | (fs.existsSync as jest.Mock).mockReturnValue(true); 111 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => true }); 112 | (fs.readdirSync as jest.Mock).mockReturnValueOnce([ 113 | 'file1.txt', 114 | 'file2.txt', 115 | 'emptyDir', 116 | 'subDir', 117 | ]); 118 | 119 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => false }); 120 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => false }); 121 | 122 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => true }); 123 | (fs.readdirSync as jest.Mock).mockReturnValueOnce([]); 124 | 125 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => true }); 126 | (fs.readdirSync as jest.Mock).mockReturnValueOnce(['subfile1.txt', 'subfile2.txt']); 127 | 128 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => false }); 129 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => false }); 130 | 131 | const unlinkSyncSpy = jest.spyOn(fs, 'unlinkSync'); 132 | const rmdirSyncSpy = jest.spyOn(fs, 'rmdirSync'); 133 | 134 | FsHelper.removeSyncRecursive('dir'); 135 | 136 | expect(unlinkSyncSpy).toHaveBeenCalledWith('dir/file1.txt'); 137 | expect(unlinkSyncSpy).toHaveBeenCalledWith('dir/file2.txt'); 138 | expect(rmdirSyncSpy).toHaveBeenCalledWith('dir/emptyDir'); 139 | expect(unlinkSyncSpy).toHaveBeenCalledWith('dir/subDir/subfile1.txt'); 140 | expect(unlinkSyncSpy).toHaveBeenCalledWith('dir/subDir/subfile2.txt'); 141 | expect(rmdirSyncSpy).toHaveBeenCalledWith('dir/subDir'); 142 | expect(rmdirSyncSpy).toHaveBeenCalledWith('dir'); 143 | }); 144 | 145 | it('should only remove paths that are within the provided input dir and not delete other files on the machine', () => { 146 | (fs.existsSync as jest.Mock).mockReturnValue(true); 147 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => true }); 148 | (fs.readdirSync as jest.Mock).mockReturnValueOnce(['subDir']); 149 | 150 | (fs.existsSync as jest.Mock).mockReturnValue(true); 151 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => true }); 152 | (fs.readdirSync as jest.Mock).mockReturnValueOnce(['subfile1.txt', 'subfile2.txt']); 153 | 154 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => false }); 155 | (fs.statSync as jest.Mock).mockReturnValueOnce({ isDirectory: () => false }); 156 | 157 | const unlinkSyncSpy = jest.spyOn(fs, 'unlinkSync'); 158 | const rmdirSyncSpy = jest.spyOn(fs, 'rmdirSync'); 159 | 160 | const inputDir = 'dir'; 161 | FsHelper.removeSyncRecursive(inputDir); 162 | 163 | expect(unlinkSyncSpy).toHaveBeenCalledWith('dir/subDir/subfile1.txt'); 164 | expect(unlinkSyncSpy).toHaveBeenCalledWith('dir/subDir/subfile2.txt'); 165 | expect(rmdirSyncSpy).toHaveBeenCalledWith('dir/subDir'); 166 | expect(rmdirSyncSpy).toHaveBeenCalledWith('dir'); 167 | 168 | const expectIsSubpath = (parentPath: string, childPath: string) => { 169 | expect(childPath.includes(parentPath)).toBe(true); 170 | expect(childPath.indexOf(parentPath) === 0).toBe(true); 171 | }; 172 | 173 | const allCalls = [...unlinkSyncSpy.mock.calls, ...rmdirSyncSpy.mock.calls]; 174 | allCalls.forEach((params) => { 175 | const inputDirPath = path.resolve(inputDir); 176 | const callPath = path.resolve(params[0] as string); 177 | expectIsSubpath(inputDirPath, callPath); 178 | }); 179 | }); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /tests/fsHelper/withoutFsMocks.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { FsHelper } from '../../src/fsHelper'; 3 | import mock from 'mock-fs'; 4 | 5 | describe('FsHelper (without mocks)', () => { 6 | beforeEach(() => { 7 | // Use in-memory file system for fs, but load real folder from tests/resources 8 | mock(); 9 | }); 10 | 11 | afterEach(() => { 12 | mock.restore(); 13 | }); 14 | 15 | describe('readdirSyncRecursive', () => { 16 | let tempDir: string; 17 | 18 | beforeEach(() => { 19 | tempDir = fs.mkdtempSync('test-fs-helper-'); 20 | }); 21 | 22 | afterEach(() => { 23 | if (fs.existsSync(tempDir)) { 24 | FsHelper.removeSyncRecursive(tempDir); 25 | } 26 | }); 27 | 28 | it('should throw an error if the directory does not exist', () => { 29 | expect(() => FsHelper.readdirSyncRecursive(`${tempDir}/nonexistent/`)).toThrowError( 30 | `Error: no such file or directory, ${tempDir}/nonexistent/`, 31 | ); 32 | }); 33 | 34 | it('should throw an error if the file does not exist', () => { 35 | fs.writeFileSync(`${tempDir}/file.txt`, 'content'); 36 | 37 | expect(() => FsHelper.readdirSyncRecursive(`${tempDir}/file.txt`)).toThrowError( 38 | `Error: not a directory, ${tempDir}/file.txt`, 39 | ); 40 | }); 41 | 42 | it('should return an empty array if the directory is empty', () => { 43 | fs.mkdirSync(`${tempDir}/emptyDir`); 44 | expect(FsHelper.readdirSyncRecursive(`${tempDir}/emptyDir`)).toEqual([]); 45 | }); 46 | 47 | it('should return an array of files and subdirectories', () => { 48 | fs.mkdirSync(`${tempDir}/dir`); 49 | fs.writeFileSync(`${tempDir}/dir/file1.txt`, 'content'); 50 | fs.mkdirSync(`${tempDir}/dir/subDir`); 51 | }); 52 | 53 | it('should return an array of files and subdirectories', () => { 54 | fs.mkdirSync(`${tempDir}/dir`); 55 | fs.writeFileSync(`${tempDir}/dir/file1.txt`, 'content'); 56 | fs.writeFileSync(`${tempDir}/dir/file2.txt`, 'content'); 57 | fs.mkdirSync(`${tempDir}/dir/emptyDir`); 58 | fs.mkdirSync(`${tempDir}/dir/subDir`); 59 | fs.writeFileSync(`${tempDir}/dir/subDir/subfile1.txt`, 'content'); 60 | fs.writeFileSync(`${tempDir}/dir/subDir/subfile2.txt`, 'content'); 61 | 62 | expect(FsHelper.readdirSyncRecursive(`${tempDir}/dir`)).toEqual( 63 | expect.arrayContaining([ 64 | 'file1.txt', 65 | 'file2.txt', 66 | 'emptyDir', 67 | 'subDir', 68 | 'subDir/subfile1.txt', 69 | 'subDir/subfile2.txt', 70 | ]), 71 | ); 72 | }); 73 | }); 74 | 75 | describe('removeSync', () => { 76 | let tempDir: string; 77 | 78 | beforeEach(() => { 79 | tempDir = fs.mkdtempSync('test-fs-helper-'); 80 | }); 81 | 82 | afterEach(() => { 83 | if (fs.existsSync(tempDir)) { 84 | FsHelper.removeSyncRecursive(tempDir); 85 | } 86 | }); 87 | 88 | it('should throw an error if the file does not exist', () => { 89 | expect(() => FsHelper.removeSyncRecursive(`${tempDir}/nonexistent.txt`)).toThrowError( 90 | `Error: no such file or directory, ${tempDir}/nonexistent.txt`, 91 | ); 92 | }); 93 | 94 | it('should remove a file', () => { 95 | fs.writeFileSync(`${tempDir}/file.txt`, 'content'); 96 | 97 | FsHelper.removeSyncRecursive(`${tempDir}/file.txt`); 98 | 99 | expect(fs.existsSync(`${tempDir}/file.txt`)).toBe(false); 100 | }); 101 | 102 | it('should remove a directory with one file in it', () => { 103 | fs.mkdirSync(`${tempDir}/dir`); 104 | fs.writeFileSync(`${tempDir}/dir/file.txt`, 'content'); 105 | 106 | FsHelper.removeSyncRecursive(`${tempDir}/dir`); 107 | 108 | expect(fs.existsSync(`${tempDir}/dir`)).toBe(false); 109 | }); 110 | 111 | it('should remove a directory and its contents, including subdirectories', () => { 112 | fs.mkdirSync(`${tempDir}/dir`); 113 | fs.writeFileSync(`${tempDir}/dir/file1.txt`, 'content'); 114 | fs.writeFileSync(`${tempDir}/dir/file2.txt`, 'content'); 115 | fs.mkdirSync(`${tempDir}/dir/emptyDir`); 116 | fs.mkdirSync(`${tempDir}/dir/subDir`); 117 | fs.writeFileSync(`${tempDir}/dir/subDir/subfile1.txt`, 'content'); 118 | fs.writeFileSync(`${tempDir}/dir/subDir/subfile2.txt`, 'content'); 119 | 120 | FsHelper.removeSyncRecursive(`${tempDir}/dir`); 121 | 122 | expect(fs.existsSync(`${tempDir}/dir`)).toBe(false); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /tests/glossary.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 DeepL SE (https://www.deepl.com) 2 | // Use of this source code is governed by an MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | import * as deepl from 'deepl-node'; 6 | 7 | import fs from 'fs'; 8 | import { makeTranslator, tempFiles, withRealServer, testTimeout } from './core'; 9 | import { v4 as randomUUID } from 'uuid'; 10 | 11 | const invalidGlossaryId = 'invalid_glossary_id'; 12 | const nonExistentGlossaryId = '96ab91fd-e715-41a1-adeb-5d701f84a483'; 13 | 14 | function getGlossaryName(): string { 15 | return `deepl-node-test-glossary: ${expect.getState().currentTestName} ${randomUUID()}`; 16 | } 17 | 18 | interface CreateManagedGlossaryArgs { 19 | name?: string; 20 | sourceLang: deepl.LanguageCode; 21 | targetLang: deepl.LanguageCode; 22 | entries: deepl.GlossaryEntries; 23 | glossaryNameSuffix?: string; 24 | } 25 | 26 | async function createManagedGlossary( 27 | translator: deepl.Translator, 28 | args: CreateManagedGlossaryArgs, 29 | ): Promise<[deepl.GlossaryInfo, () => void]> { 30 | args.glossaryNameSuffix = args?.glossaryNameSuffix || ''; 31 | args.name = args?.name || getGlossaryName() + args.glossaryNameSuffix; 32 | const glossary = await translator.createGlossary( 33 | args.name, 34 | args.sourceLang, 35 | args.targetLang, 36 | args.entries, 37 | ); 38 | 39 | const cleanupGlossary = async () => { 40 | try { 41 | await translator.deleteGlossary(glossary); 42 | } catch (e) { 43 | // Suppress errors 44 | } 45 | }; 46 | 47 | return [glossary, cleanupGlossary]; 48 | } 49 | 50 | describe('translate using glossaries', () => { 51 | it('should create glossaries', async () => { 52 | const translator = makeTranslator(); 53 | const glossaryName = getGlossaryName(); 54 | const sourceLang = 'en'; 55 | const targetLang = 'de'; 56 | const entries = new deepl.GlossaryEntries({ entries: { Hello: 'Hallo' } }); 57 | const [glossary, cleanupGlossary] = await createManagedGlossary(translator, { 58 | name: glossaryName, 59 | sourceLang, 60 | targetLang, 61 | entries, 62 | }); 63 | try { 64 | expect(glossary.name).toBe(glossaryName); 65 | expect(glossary.sourceLang).toBe(sourceLang); 66 | expect(glossary.targetLang).toBe(targetLang); 67 | // Note: ready field is indeterminate 68 | // Note: creationTime according to server might differ from local clock 69 | expect(glossary.entryCount).toBe(Object.keys(entries).length); 70 | 71 | const getResult = await translator.getGlossary(glossary.glossaryId); 72 | expect(getResult.glossaryId).toBe(glossary.glossaryId); 73 | expect(getResult.name).toBe(glossary.name); 74 | expect(getResult.sourceLang).toBe(glossary.sourceLang); 75 | expect(getResult.targetLang).toBe(glossary.targetLang); 76 | expect(getResult.creationTime.getTime()).toBe(glossary.creationTime.getTime()); 77 | expect(getResult.entryCount).toBe(glossary.entryCount); 78 | } finally { 79 | await cleanupGlossary(); 80 | } 81 | }); 82 | 83 | it('should create glossaries from CSV', async () => { 84 | const translator = makeTranslator(); 85 | const glossaryName = getGlossaryName(); 86 | const sourceLang = 'en'; 87 | const targetLang = 'de'; 88 | 89 | const expectedEntries = new deepl.GlossaryEntries({ 90 | entries: { 91 | sourceEntry1: 'targetEntry1', 92 | 'source"Entry': 'target,Entry', 93 | }, 94 | }); 95 | const csvFile = Buffer.from( 96 | 'sourceEntry1,targetEntry1,en,de\n"source""Entry","target,Entry",en,de', 97 | ); 98 | const glossary = await translator.createGlossaryWithCsv( 99 | glossaryName, 100 | sourceLang, 101 | targetLang, 102 | csvFile, 103 | ); 104 | try { 105 | const entries = await translator.getGlossaryEntries(glossary); 106 | expect(entries.entries()).toStrictEqual(expectedEntries.entries()); 107 | } finally { 108 | try { 109 | await translator.deleteGlossary(glossary); 110 | } catch (e) { 111 | // Suppress errors 112 | } 113 | } 114 | }); 115 | 116 | it('should reject creating invalid glossaries', async () => { 117 | const translator = makeTranslator(); 118 | const glossaryName = getGlossaryName(); 119 | const entries = new deepl.GlossaryEntries({ entries: { Hello: 'Hallo' } }); 120 | try { 121 | await expect(translator.createGlossary('', 'en', 'de', entries)).rejects.toThrowError( 122 | deepl.DeepLError, 123 | ); 124 | const targetLangXX = 'xx'; // Type cast to silence type-checks 125 | await expect( 126 | translator.createGlossary(glossaryName, 'en', targetLangXX, entries), 127 | ).rejects.toThrowError(deepl.DeepLError); 128 | } finally { 129 | const glossaries = await translator.listGlossaries(); 130 | for (const glossaryInfo of glossaries) { 131 | if (glossaryInfo.name === glossaryName) { 132 | await translator.deleteGlossary(glossaryInfo); 133 | } 134 | } 135 | } 136 | }); 137 | 138 | it('should get glossaries', async () => { 139 | const translator = makeTranslator(); 140 | const sourceLang = 'en'; 141 | const targetLang = 'de'; 142 | const entries = new deepl.GlossaryEntries({ entries: { Hello: 'Hallo' } }); 143 | const [createdGlossary, cleanupGlossary] = await createManagedGlossary(translator, { 144 | sourceLang, 145 | targetLang, 146 | entries, 147 | }); 148 | try { 149 | const glossary = await translator.getGlossary(createdGlossary.glossaryId); 150 | expect(glossary.glossaryId).toBe(createdGlossary.glossaryId); 151 | expect(glossary.name).toBe(createdGlossary.name); 152 | expect(glossary.sourceLang).toBe(createdGlossary.sourceLang); 153 | expect(glossary.targetLang).toBe(createdGlossary.targetLang); 154 | expect(glossary.entryCount).toBe(createdGlossary.entryCount); 155 | } finally { 156 | await cleanupGlossary(); 157 | } 158 | 159 | await expect(translator.getGlossary(invalidGlossaryId)).rejects.toThrowError( 160 | deepl.DeepLError, 161 | ); 162 | await expect(translator.getGlossary(nonExistentGlossaryId)).rejects.toThrowError( 163 | deepl.GlossaryNotFoundError, 164 | ); 165 | }); 166 | 167 | it('should get glossary entries', async () => { 168 | const translator = makeTranslator(); 169 | const entries = new deepl.GlossaryEntries({ 170 | entries: { 171 | Apple: 'Apfel', 172 | Banana: 'Banane', 173 | }, 174 | }); 175 | const [createdGlossary, cleanupGlossary] = await createManagedGlossary(translator, { 176 | sourceLang: 'en', 177 | targetLang: 'de', 178 | entries, 179 | }); 180 | try { 181 | expect((await translator.getGlossaryEntries(createdGlossary)).entries()).toStrictEqual( 182 | entries.entries(), 183 | ); 184 | expect(await translator.getGlossaryEntries(createdGlossary.glossaryId)).toStrictEqual( 185 | entries, 186 | ); 187 | } finally { 188 | await cleanupGlossary(); 189 | } 190 | 191 | await expect(translator.getGlossaryEntries(invalidGlossaryId)).rejects.toThrowError( 192 | deepl.DeepLError, 193 | ); 194 | await expect(translator.getGlossaryEntries(nonExistentGlossaryId)).rejects.toThrowError( 195 | deepl.GlossaryNotFoundError, 196 | ); 197 | }); 198 | 199 | it('should list glossaries', async () => { 200 | const translator = makeTranslator(); 201 | const [createdGlossary, cleanupGlossary] = await createManagedGlossary(translator, { 202 | sourceLang: 'en', 203 | targetLang: 'de', 204 | entries: new deepl.GlossaryEntries({ entries: { Hello: 'Hallo' } }), 205 | }); 206 | try { 207 | const glossaries = await translator.listGlossaries(); 208 | expect(glossaries).toContainEqual(createdGlossary); 209 | } finally { 210 | await cleanupGlossary(); 211 | } 212 | }); 213 | 214 | it('should delete glossaries', async () => { 215 | const translator = makeTranslator(); 216 | const [createdGlossary, cleanupGlossary] = await createManagedGlossary(translator, { 217 | sourceLang: 'en', 218 | targetLang: 'de', 219 | entries: new deepl.GlossaryEntries({ entries: { Hello: 'Hallo' } }), 220 | }); 221 | try { 222 | await translator.deleteGlossary(createdGlossary); 223 | await expect(translator.getGlossary(createdGlossary.glossaryId)).rejects.toThrowError( 224 | deepl.GlossaryNotFoundError, 225 | ); 226 | } finally { 227 | await cleanupGlossary(); 228 | } 229 | 230 | await expect(translator.deleteGlossary(invalidGlossaryId)).rejects.toThrowError( 231 | deepl.DeepLError, 232 | ); 233 | await expect(translator.deleteGlossary(nonExistentGlossaryId)).rejects.toThrowError( 234 | deepl.GlossaryNotFoundError, 235 | ); 236 | }); 237 | 238 | withRealServer('should translate text sentence using glossaries', async () => { 239 | const sourceLang = 'en'; 240 | const targetLang = 'de'; 241 | const inputText = 'The artist was awarded a prize.'; 242 | const translator = makeTranslator(); 243 | const [glossary, cleanupGlossary] = await createManagedGlossary(translator, { 244 | sourceLang, 245 | targetLang, 246 | entries: new deepl.GlossaryEntries({ 247 | entries: { 248 | artist: 'Maler', 249 | prize: 'Gewinn', 250 | }, 251 | }), 252 | }); 253 | try { 254 | const result = await translator.translateText(inputText, 'en', 'de', { glossary }); 255 | expect(result.text).toContain('Maler'); 256 | expect(result.text).toContain('Gewinn'); 257 | } finally { 258 | await cleanupGlossary(); 259 | } 260 | }); 261 | 262 | it('should create basic text using glossaries', async () => { 263 | const textsEn = ['Apple', 'Banana']; 264 | const textsDe = ['Apfel', 'Banane']; 265 | const entriesEnDe = new deepl.GlossaryEntries({ 266 | entries: { 267 | Apple: 'Apfel', 268 | Banana: 'Banane', 269 | }, 270 | }); 271 | const entriesDeEn = new deepl.GlossaryEntries({ 272 | entries: { 273 | Apfel: 'Apple', 274 | Banane: 'Banana', 275 | }, 276 | }); 277 | 278 | const translator = makeTranslator(); 279 | const [glossaryEnDe, cleanupGlossaryEnDe] = await createManagedGlossary(translator, { 280 | sourceLang: 'en', 281 | targetLang: 'de', 282 | entries: entriesEnDe, 283 | glossaryNameSuffix: '_ende', 284 | }); 285 | const [glossaryDeEn, cleanupGlossaryDeEn] = await createManagedGlossary(translator, { 286 | sourceLang: 'de', 287 | targetLang: 'en', 288 | entries: entriesDeEn, 289 | glossaryNameSuffix: '_deen', 290 | }); 291 | try { 292 | let result = await translator.translateText(textsEn, 'en', 'de', { 293 | glossary: glossaryEnDe, 294 | }); 295 | expect(result.map((textResult: deepl.TextResult) => textResult.text)).toStrictEqual( 296 | textsDe, 297 | ); 298 | 299 | result = await translator.translateText(textsDe, 'de', 'en-US', { 300 | glossary: glossaryDeEn, 301 | }); 302 | expect(result.map((textResult: deepl.TextResult) => textResult.text)).toStrictEqual( 303 | textsEn, 304 | ); 305 | } finally { 306 | await cleanupGlossaryEnDe(); 307 | await cleanupGlossaryDeEn(); 308 | } 309 | }); 310 | 311 | it( 312 | 'should translate documents using glossaries', 313 | async () => { 314 | const [exampleDocumentPath, , outputDocumentPath] = tempFiles(); 315 | const inputText = 'artist\nprize'; 316 | const expectedOutputText = 'Maler\nGewinn'; 317 | fs.writeFileSync(exampleDocumentPath, inputText); 318 | const translator = makeTranslator(); 319 | const [glossary, cleanupGlossary] = await createManagedGlossary(translator, { 320 | sourceLang: 'en', 321 | targetLang: 'de', 322 | entries: new deepl.GlossaryEntries({ 323 | entries: { 324 | artist: 'Maler', 325 | prize: 'Gewinn', 326 | }, 327 | }), 328 | }); 329 | 330 | try { 331 | await translator.translateDocument( 332 | exampleDocumentPath, 333 | outputDocumentPath, 334 | 'en', 335 | 'de', 336 | { glossary }, 337 | ); 338 | expect(fs.readFileSync(outputDocumentPath).toString()).toBe(expectedOutputText); 339 | } finally { 340 | await cleanupGlossary(); 341 | } 342 | }, 343 | testTimeout, 344 | ); // Increased timeout for test involving translation 345 | 346 | withRealServer( 347 | 'should reject translating invalid text with glossaries', 348 | async () => { 349 | const text = 'Test'; 350 | const entries = new deepl.GlossaryEntries({ entries: { Hello: 'Hallo' } }); 351 | const translator = makeTranslator(); 352 | const [glossaryEnDe, cleanupGlossaryEnDe] = await createManagedGlossary(translator, { 353 | sourceLang: 'en', 354 | targetLang: 'de', 355 | entries, 356 | glossaryNameSuffix: '_ende', 357 | }); 358 | const [glossaryDeEn, cleanupGlossaryDeEn] = await createManagedGlossary(translator, { 359 | sourceLang: 'de', 360 | targetLang: 'en', 361 | entries, 362 | glossaryNameSuffix: '_deen', 363 | }); 364 | try { 365 | await expect( 366 | translator.translateText(text, null, 'de', { glossary: glossaryEnDe }), 367 | ).rejects.toThrowError('sourceLang is required'); 368 | await expect( 369 | translator.translateText(text, 'de', 'en-US', { glossary: glossaryEnDe }), 370 | ).rejects.toThrowError('No dictionary found for language pair'); 371 | const targetLangEn = 'en'; // Type cast to silence type-checks 372 | await expect( 373 | translator.translateText(text, 'de', targetLangEn, { glossary: glossaryDeEn }), 374 | ).rejects.toThrowError("targetLang='en' is deprecated"); 375 | } finally { 376 | await cleanupGlossaryEnDe(); 377 | await cleanupGlossaryDeEn(); 378 | } 379 | }, 380 | testTimeout, 381 | ); // Increased timeout for test involving translation 382 | }); 383 | -------------------------------------------------------------------------------- /tests/rephraseText.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2025 DeepL SE (https://www.deepl.com) 2 | // Use of this source code is governed by an MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | import { exampleText, makeDeeplClient, testTimeout, usingMockServer, withRealServer } from './core'; 6 | 7 | import { WritingStyle, WritingTone } from './../src/deeplClient'; 8 | 9 | describe('rephrase text', () => { 10 | it('should rephrase a single text', async () => { 11 | const deeplClient = makeDeeplClient(); 12 | const result = await deeplClient.rephraseText(exampleText.de, 'de'); 13 | expect(result.text).toBe(exampleText.de); 14 | expect(result.detectedSourceLang).toBe('de'); 15 | expect(result.targetLang).toBe('de'); 16 | }); 17 | 18 | it('should throw an error for unsupported languages', async () => { 19 | const deeplClient = makeDeeplClient(); 20 | const deeplClientPromise = deeplClient.rephraseText(exampleText.de, 'ja'); 21 | await expect(deeplClientPromise).rejects.toBeInstanceOf(Error); 22 | await expect(deeplClientPromise).rejects.toThrow(/Value for '?target_lang'? not supported/); 23 | }); 24 | 25 | it('should throw an error for unsupported tone', async () => { 26 | const deeplClient = makeDeeplClient(); 27 | const deeplClientPromise = deeplClient.rephraseText( 28 | exampleText.de, 29 | 'es', 30 | null, 31 | WritingTone.CONFIDENT, 32 | ); 33 | await expect(deeplClientPromise).rejects.toBeInstanceOf(Error); 34 | await expect(deeplClientPromise).rejects.toThrow( 35 | /Language Spanish does not support setting a tone/, 36 | ); 37 | }); 38 | 39 | it('should throw an error for invalid writing_style parameter', async () => { 40 | const deeplClient = makeDeeplClient(); 41 | const deeplClientPromise = deeplClient.rephraseText( 42 | exampleText.de, 43 | 'es', 44 | WritingStyle.BUSINESS, 45 | null, 46 | ); 47 | await expect(deeplClientPromise).rejects.toBeInstanceOf(Error); 48 | await expect(deeplClientPromise).rejects.toThrow( 49 | /Language Spanish does not support setting a writing style/, 50 | ); 51 | }); 52 | 53 | it('should throw an error if both style and tone are provided', async () => { 54 | const deeplClient = makeDeeplClient(); 55 | const deeplClientPromise = deeplClient.rephraseText( 56 | exampleText.en, 57 | 'en', 58 | WritingStyle.BUSINESS, 59 | WritingTone.CONFIDENT, 60 | ); 61 | await expect(deeplClientPromise).rejects.toBeInstanceOf(Error); 62 | await expect(deeplClientPromise).rejects.toThrow(/Both writing_style and tone defined/); 63 | }); 64 | 65 | it('should return success if style is default and tone is provided', async () => { 66 | const deeplClient = makeDeeplClient(); 67 | const rephraseResponse = await deeplClient.rephraseText( 68 | exampleText.en, 69 | 'en', 70 | WritingStyle.DEFAULT, 71 | WritingTone.CONFIDENT, 72 | ); 73 | expect(rephraseResponse.text).toBeDefined(); 74 | expect(rephraseResponse.detectedSourceLang).toBeDefined(); 75 | expect(rephraseResponse.targetLang).toBe('en-US'); 76 | }); 77 | 78 | it('should return success if style is provided and tone is default', async () => { 79 | const deeplClient = makeDeeplClient(); 80 | const rephraseResponse = await deeplClient.rephraseText( 81 | exampleText.de, 82 | 'en', 83 | WritingStyle.ACADEMIC, 84 | WritingTone.DEFAULT, 85 | ); 86 | expect(rephraseResponse.text).toBeDefined(); 87 | expect(rephraseResponse.detectedSourceLang).toBeDefined(); 88 | expect(rephraseResponse.targetLang).toBe('en-US'); 89 | }); 90 | 91 | it('should return success if both style and tone are not provided', async () => { 92 | const deeplClient = makeDeeplClient(); 93 | const rephraseResponse = await deeplClient.rephraseText( 94 | exampleText.de, 95 | 'en', 96 | null, 97 | undefined, 98 | ); 99 | expect(rephraseResponse.text).toBeDefined(); 100 | expect(rephraseResponse.detectedSourceLang).toBeDefined(); 101 | expect(rephraseResponse.targetLang).toBe('en-US'); 102 | }); 103 | 104 | it( 105 | 'should rephrase with style and tone', 106 | async () => { 107 | const deeplClient = makeDeeplClient(); 108 | const input = 'How are yo dong guys?'; 109 | 110 | const outputConfident = usingMockServer 111 | ? 'proton beam' 112 | : "Tell me how you're doing, guys."; 113 | expect( 114 | (await deeplClient.rephraseText(input, 'en', null, WritingTone.CONFIDENT)).text, 115 | ).toBe(outputConfident); 116 | 117 | const outputBusiness = usingMockServer 118 | ? 'proton beam' 119 | : 'Greetings, gentlemen. How are you?'; 120 | expect( 121 | (await deeplClient.rephraseText(input, 'en', WritingStyle.BUSINESS, null)).text, 122 | ).toBe(outputBusiness); 123 | }, 124 | testTimeout, 125 | ); 126 | }); 127 | -------------------------------------------------------------------------------- /tests/resources/example_document_template.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeepLcom/deepl-node/7cf8ba64ca3fdf1fb8796d59c291d759328447b6/tests/resources/example_document_template.docx -------------------------------------------------------------------------------- /tests/resources/example_presentation_template.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeepLcom/deepl-node/7cf8ba64ca3fdf1fb8796d59c291d759328447b6/tests/resources/example_presentation_template.pptx -------------------------------------------------------------------------------- /tests/resources/example_zip_template.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeepLcom/deepl-node/7cf8ba64ca3fdf1fb8796d59c291d759328447b6/tests/resources/example_zip_template.zip -------------------------------------------------------------------------------- /tests/temp/media/parent/image.png: -------------------------------------------------------------------------------- 1 | image content -------------------------------------------------------------------------------- /tests/translateDocument.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 DeepL SE (https://www.deepl.com) 2 | // Use of this source code is governed by an MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | import * as deepl from 'deepl-node'; 6 | 7 | import nock from 'nock'; 8 | 9 | import fs from 'fs'; 10 | import path from 'path'; 11 | import util from 'util'; 12 | 13 | import { 14 | documentTranslationTestTimeout, 15 | exampleDocumentInput, 16 | exampleDocumentOutput, 17 | exampleText, 18 | makeTranslator, 19 | tempFiles, 20 | testTimeout, 21 | timeout, 22 | urlToMockRegexp, 23 | withMockServer, 24 | withRealServer, 25 | } from './core'; 26 | import { DocumentTranslateOptions, QuotaExceededError } from 'deepl-node'; 27 | 28 | describe('translate document', () => { 29 | it( 30 | 'should translate using file paths', 31 | async () => { 32 | const translator = makeTranslator(); 33 | const [exampleDocument, , outputDocumentPath] = tempFiles(); 34 | await translator.translateDocument(exampleDocument, outputDocumentPath, null, 'de'); 35 | expect(fs.readFileSync(outputDocumentPath).toString()).toBe(exampleDocumentOutput); 36 | }, 37 | testTimeout, 38 | ); 39 | 40 | it('should not translate to existing output files', async () => { 41 | const translator = makeTranslator(); 42 | const [exampleDocument, , outputDocumentPath] = tempFiles(); 43 | fs.writeFileSync(outputDocumentPath, fs.readFileSync(exampleDocument).toString()); 44 | await expect( 45 | translator.translateDocument(exampleDocument, outputDocumentPath, null, 'de'), 46 | ).rejects.toThrow('exists'); 47 | }); 48 | 49 | it('should not translate non-existent files', async () => { 50 | const translator = makeTranslator(); 51 | const [, , outputDocumentPath] = tempFiles(); 52 | await expect( 53 | translator.translateDocument('nonExistentFile.txt', outputDocumentPath, null, 'de'), 54 | ).rejects.toThrow('no such file'); 55 | }); 56 | 57 | it( 58 | 'should translate using file streams', 59 | async () => { 60 | const translator = makeTranslator(); 61 | const [exampleDocument, , outputDocumentPath] = tempFiles(); 62 | 63 | const inputFileStream = fs.createReadStream(exampleDocument, { flags: 'r' }); 64 | 65 | // Omitting the filename parameter will result in error 66 | await expect(translator.uploadDocument(inputFileStream, null, 'de')).rejects.toThrow( 67 | 'options.filename', 68 | ); 69 | 70 | const handle = await translator.uploadDocument(inputFileStream, null, 'de', { 71 | filename: 'test.txt', 72 | }); 73 | const { status } = await translator.isDocumentTranslationComplete(handle); 74 | expect(status.ok() && status.done()).toBeTruthy(); 75 | 76 | const outputFileStream = fs.createWriteStream(outputDocumentPath, { flags: 'wx' }); 77 | await translator.downloadDocument(handle, outputFileStream); 78 | expect(fs.readFileSync(outputDocumentPath).toString()).toBe(exampleDocumentOutput); 79 | }, 80 | testTimeout, 81 | ); 82 | 83 | it( 84 | 'should translate using buffer input', 85 | async () => { 86 | const translator = makeTranslator(); 87 | const [exampleDocument, , outputDocumentPath] = tempFiles(); 88 | const inputBuffer = await fs.promises.readFile(exampleDocument); 89 | await translator.translateDocument(inputBuffer, outputDocumentPath, null, 'de', { 90 | filename: exampleDocument, 91 | }); 92 | expect(fs.readFileSync(outputDocumentPath).toString()).toBe(exampleDocumentOutput); 93 | }, 94 | testTimeout, 95 | ); 96 | 97 | it( 98 | 'should translate using file handles', 99 | async () => { 100 | const translator = makeTranslator(); 101 | const [exampleDocument, , outputDocumentPath] = tempFiles(); 102 | const inputHandle = await fs.promises.open(exampleDocument, 'r'); 103 | const outputHandle = await fs.promises.open(outputDocumentPath, 'w'); 104 | await translator.translateDocument(inputHandle, outputHandle, null, 'de', { 105 | filename: exampleDocument, 106 | }); 107 | expect(fs.readFileSync(outputDocumentPath).toString()).toBe(exampleDocumentOutput); 108 | }, 109 | testTimeout, 110 | ); 111 | 112 | withMockServer('should translate with retries', async () => { 113 | const translator = makeTranslator({ minTimeout: 100, mockServerNoResponseTimes: 1 }); 114 | const [exampleDocument, , outputDocumentPath] = tempFiles(); 115 | await translator.translateDocument(exampleDocument, outputDocumentPath, null, 'de'); 116 | expect(fs.readFileSync(outputDocumentPath).toString()).toBe(exampleDocumentOutput); 117 | }); 118 | 119 | withMockServer( 120 | 'should translate with waiting', 121 | async () => { 122 | const translator = makeTranslator({ 123 | mockServerDocQueueTime: 2000, 124 | mockServerDocTranslateTime: 2000, 125 | }); 126 | const [exampleDocument, , outputDocumentPath] = tempFiles(); 127 | await translator.translateDocument(exampleDocument, outputDocumentPath, null, 'de'); 128 | expect(fs.readFileSync(outputDocumentPath).toString()).toBe(exampleDocumentOutput); 129 | }, 130 | documentTranslationTestTimeout, 131 | ); 132 | 133 | withRealServer( 134 | 'should translate using formality', 135 | async () => { 136 | const unlinkP = util.promisify(fs.unlink); 137 | const translator = makeTranslator(); 138 | const [exampleDocument, , outputDocumentPath] = tempFiles(); 139 | fs.writeFileSync(exampleDocument, 'How are you?'); 140 | 141 | await translator.translateDocument(exampleDocument, outputDocumentPath, null, 'de', { 142 | formality: 'more', 143 | }); 144 | expect(fs.readFileSync(outputDocumentPath).toString()).toBe('Wie geht es Ihnen?'); 145 | await unlinkP(outputDocumentPath); 146 | 147 | await translator.translateDocument(exampleDocument, outputDocumentPath, null, 'de', { 148 | formality: 'default', 149 | }); 150 | expect(fs.readFileSync(outputDocumentPath).toString()).toBe('Wie geht es Ihnen?'); 151 | await unlinkP(outputDocumentPath); 152 | 153 | await translator.translateDocument(exampleDocument, outputDocumentPath, null, 'de', { 154 | formality: 'less', 155 | }); 156 | expect(fs.readFileSync(outputDocumentPath).toString()).toBe('Wie geht es dir?'); 157 | }, 158 | testTimeout, 159 | ); 160 | 161 | it('should reject invalid formality', async () => { 162 | const translator = makeTranslator(); 163 | const [exampleDocument, , outputDocumentPath] = tempFiles(); 164 | const formality = 'formality'; // Type cast to silence type-checks 165 | await expect( 166 | translator.translateDocument(exampleDocument, outputDocumentPath, null, 'de', { 167 | formality, 168 | }), 169 | ).rejects.toThrow('formality'); 170 | }); 171 | 172 | it( 173 | 'should handle document failure', 174 | async () => { 175 | const translator = makeTranslator(); 176 | const [exampleDocument, , outputDocumentPath] = tempFiles(); 177 | fs.writeFileSync(exampleDocument, exampleText.de); 178 | // Translating text from DE to DE will trigger error 179 | const promise = translator.translateDocument( 180 | exampleDocument, 181 | outputDocumentPath, 182 | null, 183 | 'de', 184 | ); 185 | await expect(promise).rejects.toThrow(/Source and target language/); 186 | }, 187 | documentTranslationTestTimeout, 188 | ); 189 | 190 | it('should reject invalid document', async () => { 191 | const translator = makeTranslator(); 192 | const [, , , tempDir] = tempFiles(); 193 | const invalidFile = path.join(tempDir, 'document.invalid'); 194 | fs.writeFileSync(invalidFile, 'Test'); 195 | await expect( 196 | translator.translateDocument(invalidFile, exampleDocumentOutput, null, 'de'), 197 | ).rejects.toThrow(/(nvalid file)|(file extension)/); 198 | }); 199 | 200 | withMockServer( 201 | 'should support low level use', 202 | async () => { 203 | // Set a small document queue time to attempt downloading a queued document 204 | const translator = makeTranslator({ 205 | mockServerDocQueueTime: 100, 206 | }); 207 | const [exampleDocument, , outputDocumentPath] = tempFiles(); 208 | let handle = await translator.uploadDocument(exampleDocument, null, 'de'); 209 | let status = await translator.getDocumentStatus(handle); 210 | expect(status.ok()).toBe(true); 211 | 212 | // Test recreating handle as an object 213 | handle = { documentId: handle.documentId, documentKey: handle.documentKey }; 214 | status = await translator.getDocumentStatus(handle); 215 | expect(status.ok()).toBe(true); 216 | 217 | while (status.ok() && !status.done()) { 218 | status = await translator.getDocumentStatus(handle); 219 | await timeout(200); 220 | } 221 | 222 | expect(status.ok()).toBe(true); 223 | expect(status.done()).toBe(true); 224 | await translator.downloadDocument(handle, outputDocumentPath); 225 | expect(fs.readFileSync(outputDocumentPath).toString()).toBe(exampleDocumentOutput); 226 | }, 227 | testTimeout, 228 | ); 229 | 230 | withMockServer( 231 | 'should provide billed characters in document status', 232 | async () => { 233 | const translator = makeTranslator({ 234 | mockServerDocQueueTime: 2000, 235 | mockServerDocTranslateTime: 2000, 236 | }); 237 | const [exampleDocument, , outputDocumentPath] = tempFiles(); 238 | 239 | const timeBefore = Date.now(); 240 | const handle = await translator.uploadDocument(exampleDocument, null, 'de'); 241 | const status = await translator.getDocumentStatus(handle); 242 | expect(status.ok()).toBe(true); 243 | expect(status.done()).toBe(false); 244 | 245 | const { handle: handleResult, status: statusResult } = 246 | await translator.isDocumentTranslationComplete(handle); 247 | expect(handle.documentId).toBe(handleResult.documentId); 248 | expect(handle.documentKey).toBe(handleResult.documentKey); 249 | 250 | expect(statusResult.ok()).toBe(true); 251 | expect(statusResult.done()).toBe(true); 252 | await translator.downloadDocument(handle, outputDocumentPath); 253 | const timeAfter = Date.now(); 254 | 255 | // Elapsed time should be at least 4 seconds 256 | expect(timeAfter - timeBefore).toBeGreaterThan(4000); 257 | expect(fs.readFileSync(outputDocumentPath).toString()).toBe(exampleDocumentOutput); 258 | expect(statusResult.billedCharacters).toBe(exampleDocumentInput.length); 259 | }, 260 | documentTranslationTestTimeout, 261 | ); 262 | 263 | it('should reject not found document handles', async () => { 264 | const handle = { documentId: '1234'.repeat(8), documentKey: '5678'.repeat(16) }; 265 | const translator = makeTranslator(); 266 | await expect(translator.getDocumentStatus(handle)).rejects.toThrow('Not found'); 267 | }); 268 | 269 | describe('request parameter tests', () => { 270 | beforeAll(() => { 271 | nock.disableNetConnect(); 272 | }); 273 | 274 | it('sends extra request parameters', async () => { 275 | nock(urlToMockRegexp) 276 | .post('/v2/document', function (body) { 277 | // Nock unfortunately does not support proper form data matching 278 | // See https://github.com/nock/nock/issues/887 279 | // And https://github.com/nock/nock/issues/191 280 | expect(body).toContain('form-data'); 281 | expect(body).toContain('my-extra-parameter'); 282 | expect(body).toContain('my-extra-value'); 283 | return true; 284 | }) 285 | .reply(456); 286 | const translator = makeTranslator(); 287 | const dataBuf = Buffer.from('Example file contents', 'utf8'); 288 | const options: DocumentTranslateOptions = { 289 | filename: 'example.txt', 290 | extraRequestParameters: { 'my-extra-parameter': 'my-extra-value' }, 291 | }; 292 | await expect(translator.uploadDocument(dataBuf, null, 'de', options)).rejects.toThrow( 293 | QuotaExceededError, 294 | ); 295 | }); 296 | }); 297 | }); 298 | -------------------------------------------------------------------------------- /tests/translateText.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 DeepL SE (https://www.deepl.com) 2 | // Use of this source code is governed by an MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | import * as deepl from 'deepl-node'; 6 | 7 | import nock from 'nock'; 8 | 9 | import { 10 | exampleText, 11 | makeTranslator, 12 | testTimeout, 13 | urlToMockRegexp, 14 | withMockServer, 15 | withRealServer, 16 | } from './core'; 17 | import { ModelType, QuotaExceededError, TranslateTextOptions } from 'deepl-node'; 18 | 19 | describe('translate text', () => { 20 | it('should translate a single text', async () => { 21 | const translator = makeTranslator(); 22 | const result = await translator.translateText(exampleText.en, null, 'de'); 23 | expect(result.text).toBe(exampleText.de); 24 | expect(result.detectedSourceLang).toBe('en'); 25 | expect(result.billedCharacters).toBe(exampleText.en.length); 26 | }); 27 | 28 | it.each([['quality_optimized'], ['latency_optimized'], ['prefer_quality_optimized']])( 29 | 'should translate using model_type = %s', 30 | async (modelTypeStr) => { 31 | const translator = makeTranslator(); 32 | const modelType = modelTypeStr as ModelType; 33 | const result = await translator.translateText(exampleText.en, 'en', 'de', { 34 | modelType: modelType, 35 | }); 36 | const expectedModelTypeUsed = modelType.replace('prefer_', ''); 37 | expect(result.modelTypeUsed).toBe(expectedModelTypeUsed); 38 | }, 39 | ); 40 | 41 | it('should translate an array of texts', async () => { 42 | const translator = makeTranslator(); 43 | const result = await translator.translateText([exampleText.fr, exampleText.en], null, 'de'); 44 | expect(result[0].text).toBe(exampleText.de); 45 | expect(result[0].detectedSourceLang).toBe('fr'); 46 | expect(result[1].text).toBe(exampleText.de); 47 | expect(result[1].detectedSourceLang).toBe('en'); 48 | }); 49 | 50 | it('should accept language codes in any case', async () => { 51 | const translator = makeTranslator(); 52 | let result = await translator.translateText(exampleText.en, 'en', 'de'); 53 | expect(result.text).toBe(exampleText.de); 54 | expect(result.detectedSourceLang).toBe('en'); 55 | 56 | const sourceLangEn = 'eN'; // Type cast to silence type-checks 57 | const targetLangDe = 'De'; // Type cast to silence type-checks 58 | result = await translator.translateText(exampleText.en, sourceLangEn, targetLangDe); 59 | expect(result.text).toBe(exampleText.de); 60 | expect(result.detectedSourceLang).toBe('en'); 61 | }); 62 | 63 | withMockServer('should translate using overridden path', async () => { 64 | const translator = makeTranslator(); 65 | const result = await translator.translateText(exampleText.en, null, 'de', { 66 | __path: '/v2/translate_secondary', 67 | }); 68 | expect(result.text).toBe(exampleText.de); 69 | expect(result.detectedSourceLang).toBe('en'); 70 | expect(result.billedCharacters).toBe(exampleText.en.length); 71 | }); 72 | 73 | it('should reject deprecated target codes', async () => { 74 | const translator = makeTranslator(); 75 | 76 | const targetLangEn = 'en'; // Type cast to silence type-checks 77 | await expect(translator.translateText(exampleText.de, null, targetLangEn)).rejects.toThrow( 78 | /deprecated/, 79 | ); 80 | 81 | const targetLangPt = 'pt'; // Type cast to silence type-checks 82 | await expect(translator.translateText(exampleText.de, null, targetLangPt)).rejects.toThrow( 83 | /deprecated/, 84 | ); 85 | }); 86 | 87 | it('should reject invalid language codes', async () => { 88 | const translator = makeTranslator(); 89 | 90 | const sourceLangInvalid = 'xx'; // Type cast to silence type-checks 91 | await expect( 92 | translator.translateText(exampleText.de, sourceLangInvalid, 'en-US'), 93 | ).rejects.toThrow('source_lang'); 94 | 95 | const targetLangInvalid = 'xx'; // Type cast to silence type-checks 96 | await expect( 97 | translator.translateText(exampleText.de, null, targetLangInvalid), 98 | ).rejects.toThrow('target_lang'); 99 | }); 100 | 101 | it('should reject empty texts', async () => { 102 | const translator = makeTranslator(); 103 | await expect(translator.translateText('', null, 'de')).rejects.toThrow('texts parameter'); 104 | await expect(translator.translateText([''], null, 'de')).rejects.toThrow('texts parameter'); 105 | }); 106 | 107 | withMockServer('should retry 429s with delay', async () => { 108 | const translator = makeTranslator({ mockServer429ResponseTimes: 2 }); 109 | const timeBefore = Date.now(); 110 | await translator.translateText(exampleText.en, null, 'de'); 111 | const timeAfter = Date.now(); 112 | // Elapsed time should be at least 1 second 113 | expect(timeAfter - timeBefore).toBeGreaterThan(1000); 114 | }); 115 | 116 | withRealServer( 117 | 'should translate with formality', 118 | async () => { 119 | const translator = makeTranslator(); 120 | const input = 'How are you?'; 121 | const formal = 'Wie geht es Ihnen?'; 122 | const informal = 'Wie geht es dir?'; 123 | expect((await translator.translateText(input, null, 'de')).text).toBe(formal); 124 | expect( 125 | (await translator.translateText(input, null, 'de', { formality: 'less' })).text, 126 | ).toBe(informal); 127 | expect( 128 | (await translator.translateText(input, null, 'de', { formality: 'default' })).text, 129 | ).toBe(formal); 130 | expect( 131 | (await translator.translateText(input, null, 'de', { formality: 'more' })).text, 132 | ).toBe(formal); 133 | 134 | const formalityLess = 'LESS'; // Type cast to silence type-checks 135 | expect( 136 | (await translator.translateText(input, null, 'de', { formality: formalityLess })) 137 | .text, 138 | ).toBe(informal); 139 | 140 | const formalityDefault = 'DEFAULT'; // Type cast to silence type-checks 141 | expect( 142 | (await translator.translateText(input, null, 'de', { formality: formalityDefault })) 143 | .text, 144 | ).toBe(formal); 145 | 146 | const formalityMore = 'MORE'; // Type cast to silence type-checks 147 | expect( 148 | (await translator.translateText(input, null, 'de', { formality: formalityMore })) 149 | .text, 150 | ).toBe(formal); 151 | 152 | expect( 153 | (await translator.translateText(input, null, 'de', { formality: 'prefer_less' })) 154 | .text, 155 | ).toBe(informal); 156 | expect( 157 | (await translator.translateText(input, null, 'de', { formality: 'prefer_more' })) 158 | .text, 159 | ).toBe(formal); 160 | 161 | // Using prefer_* with a language that does not support formality is not an error 162 | await translator.translateText(input, null, 'tr', { formality: 'prefer_more' }); 163 | await expect( 164 | translator.translateText(input, null, 'tr', { formality: 'more' }), 165 | ).rejects.toThrow('formality'); 166 | }, 167 | testTimeout, 168 | ); 169 | 170 | it('should reject invalid formality', async () => { 171 | const translator = makeTranslator(); 172 | const invalidFormality = 'invalid'; // Type cast to silence type-checks 173 | await expect( 174 | translator.translateText('Test', null, 'de', { formality: invalidFormality }), 175 | ).rejects.toThrow('formality'); 176 | }); 177 | 178 | it('should translate with split sentences', async () => { 179 | const translator = makeTranslator(); 180 | const input = 'The firm said it had been\nconducting an internal investigation.'; 181 | await translator.translateText(input, null, 'de', { splitSentences: 'off' }); 182 | await translator.translateText(input, null, 'de', { splitSentences: 'on' }); 183 | await translator.translateText(input, null, 'de', { splitSentences: 'nonewlines' }); 184 | await translator.translateText(input, null, 'de', { splitSentences: 'default' }); 185 | 186 | // Invalid sentence splitting modes are rejected 187 | const invalidSplitSentences = 'invalid'; // Type cast to silence type-checks 188 | await expect( 189 | translator.translateText(input, null, 'de', { splitSentences: invalidSplitSentences }), 190 | ).rejects.toThrow('split_sentences'); 191 | }); 192 | 193 | it('should translate with preserve formatting', async () => { 194 | const translator = makeTranslator(); 195 | const input = exampleText.en; 196 | await translator.translateText(input, null, 'de', { preserveFormatting: false }); 197 | await translator.translateText(input, null, 'de', { preserveFormatting: true }); 198 | }); 199 | 200 | it('should translate with tag_handling option', async () => { 201 | const text = 202 | '\ 203 | \n\ 204 | \n\ 205 | \n\ 206 |

This is an example sentence.

\n\ 207 | \n\ 208 | '; 209 | const translator = makeTranslator(); 210 | // Note: this test may use the mock server that will not translate the text, 211 | // therefore we do not check the translated result. 212 | await translator.translateText(text, null, 'de', { tagHandling: 'xml' }); 213 | await translator.translateText(text, null, 'de', { tagHandling: 'html' }); 214 | }); 215 | 216 | it('should translate with context option', async () => { 217 | const translator = makeTranslator(); 218 | const text = 'Das ist scharf!'; 219 | // In German, "scharf" can mean: 220 | // - spicy/hot when referring to food, or 221 | // - sharp when referring to other objects such as a knife (Messer). 222 | await translator.translateText(text, null, 'en-US'); 223 | // Result: "That is hot!" 224 | await translator.translateText(text, null, 'en-US', { context: 'Das ist ein Messer' }); 225 | // Result: "That is sharp!" 226 | }); 227 | 228 | withRealServer('should translate using specified XML tags', async () => { 229 | const translator = makeTranslator(); 230 | const text = 231 | "\ 232 | \n\ 233 | \n\ 234 | A document's title\n\ 235 | \n\ 236 | \n\ 237 | \n\ 238 | This is a sentence splitacross two <span> tags that should be treated as one.\n\ 239 | \n\ 240 | Here is a sentence. Followed by a second one.\n\ 241 | This sentence will not be translated.\n\ 242 | \n\ 243 | "; 244 | const result = await translator.translateText(text, null, 'de', { 245 | tagHandling: 'xml', 246 | outlineDetection: false, 247 | nonSplittingTags: 'span', 248 | splittingTags: ['title', 'par'], 249 | ignoreTags: ['raw'], 250 | }); 251 | expect(result.text).toContain('This sentence will not be translated.'); 252 | expect(result.text).toContain('Der Titel'); 253 | }); 254 | 255 | withRealServer('should translate using specified HTML tags', async () => { 256 | const translator = makeTranslator(); 257 | const text = 258 | '\ 259 | <!DOCTYPE html>\n\ 260 | <html>\n\ 261 | <body>\n\ 262 | <h1>My First Heading</h1>\n\ 263 | <p translate="no">My first paragraph.</p>\n\ 264 | </body>\n\ 265 | </html>'; 266 | 267 | const result = await translator.translateText(text, null, 'de', { tagHandling: 'html' }); 268 | 269 | expect(result.text).toContain('<h1>Meine erste Überschrift</h1>'); 270 | expect(result.text).toContain('<p translate="no">My first paragraph.</p>'); 271 | }); 272 | 273 | describe('request parameter tests', () => { 274 | beforeAll(() => { 275 | nock.disableNetConnect(); 276 | }); 277 | 278 | it('sends extra request parameters', async () => { 279 | nock(urlToMockRegexp) 280 | .post('/v2/translate', function (body) { 281 | expect(body['my-extra-parameter']).toBe('my-extra-value'); 282 | return true; 283 | }) 284 | .reply(456); 285 | const translator = makeTranslator(); 286 | const options: TranslateTextOptions = { 287 | extraRequestParameters: { 'my-extra-parameter': 'my-extra-value' }, 288 | }; 289 | await expect(translator.translateText('test', null, 'de', options)).rejects.toThrow( 290 | QuotaExceededError, 291 | ); 292 | }); 293 | }); 294 | }); 295 | -------------------------------------------------------------------------------- /tsconfig-eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "baseUrl": "src", 5 | "declaration": true, 6 | "downlevelIteration": true, 7 | "esModuleInterop": true, 8 | "lib": ["esnext"], 9 | "moduleResolution": "node", 10 | "noFallthroughCasesInSwitch": true, 11 | "paths": { "deepl-node": ["."] }, 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "types": ["node", "jest"], 15 | "module": "commonjs", 16 | "outDir": "dist", 17 | "target": "es2016" 18 | }, 19 | "compileOnSave": false, 20 | "exclude": ["dist", "node_modules"], 21 | "include": ["tests", "src", "examples"] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "baseUrl": "src", 5 | "declaration": true, 6 | "downlevelIteration": true, 7 | "esModuleInterop": true, 8 | "lib": ["esnext"], 9 | "moduleResolution": "node", 10 | "noFallthroughCasesInSwitch": true, 11 | "paths": { "deepl-node": ["."] }, 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "types": ["node", "jest"], 15 | "module": "commonjs", 16 | "outDir": "dist", 17 | "target": "es2019" 18 | }, 19 | "compileOnSave": false, 20 | "exclude": ["tests", "dist", "node_modules"], 21 | "include": ["src"] 22 | } 23 | -------------------------------------------------------------------------------- /upgrading_to_multilingual_glossaries.md: -------------------------------------------------------------------------------- 1 | # Migration Documentation for Newest Glossary Functionality 2 | 3 | ## 1. Overview of Changes 4 | The newest version of the Glossary APIs is the `/v3` endpoints, which introduce enhanced functionality: 5 | - **Support for Multilingual Glossaries**: The v3 endpoints allow for the creation of glossaries with multiple language pairs, enhancing flexibility and usability. 6 | - **Editing Capabilities**: Users can now edit existing glossaries. 7 | 8 | To support these new v3 APIs, we have created new methods to interact with these new multilingual glossaries. Users are encouraged to transition to the new to take full advantage of these new features. The `v2` methods for monolingual glossaries (e.g., `createGlossary()`, `getGlossary()`, etc.) remain available, however users are encouraged to update to use the new functions. 9 | 10 | ## 2. Endpoint Changes 11 | 12 | | Monolingual glossary methods | Multilingual glossary methods | Changes Summary | 13 | |--------------------------------------|--------------------------------------|----------------------------------------------------------| 14 | | `createGlossary()` | `createMultilingualGlossary()` | Accepts an array of `MultilingualGlossaryDictionaryEntries` for multi-lingual support and now returns a `MultilingualGlossaryInfo` object. | 15 | | `createGlossaryWithCsv()` | `createMultilingualGlossaryWithCsv()` | Similar functionality, but now returns a `MultilingualGlossaryInfo` object | 16 | | `getGlossary()` | `getMultilingualGlossary()` | Similar functionality, but now returns `MultilingualGlossaryInfo`. Also can accept a `MultilingualGlossaryInfo` object as the glossary parameter instead of a `GlossaryInfo` object.| 17 | | `listGlossaries()` | `listMultilingualGlossaries()` | Similar functionality, but now returns a list of `MultilingualGlossaryInfo` objects. | 18 | | `getGlossaryEntries()` | `getMultilingualGlossaryDictionaryEntries()` | Requires specifying source and target languages. Also returns a `MultilingualGlossaryDictionaryEntries` object as the response. | 19 | | `deleteGlossary()` | `deleteMultilingualGlossary()` | Similar functionality, but now can accept a `MultilingualGlossaryInfo` object instead of a `GlossaryInfo` object when specifying the glossary. | 20 | 21 | ## 3. Model Changes 22 | V2 glossaries are monolingual and the previous glossary objects could only have entries for one language pair (`sourceLang` and `targetLang`). Now we introduce the concept of "glossary dictionaries", where a glossary dictionary specifies its own `sourceLangCode`, `targetLangCode`, and has its own entries. 23 | 24 | - **Glossary Information**: 25 | - **v2**: `GlossaryInfo` supports only mono-lingual glossaries, containing fields such as `sourceLang`, `targetLang`, and `entryCount`. 26 | - **v3**: `MultilingualGlossaryInfo` supports multi-lingual glossaries and includes a list of `MultilingualGlossaryDictionaryInfo`, which provides details about each glossary dictionary, each with its own `sourceLangCode`, `targetLangCode`, and `entryCount`. 27 | 28 | - **Glossary Entries**: 29 | - **v3**: Introduces `MultilingualGlossaryDictionaryEntries`, which encapsulates a glossary dictionary with source and target languages along with its entries. 30 | 31 | ## 4. Code Examples 32 | 33 | ### Create a glossary 34 | 35 | ```javascript 36 | // monolingual glossary example 37 | const glossaryInfo = await deeplClient.createGlossary('My Glossary', 'en', 'de', new deepl.GlossaryEntries({ entries: { Hello: 'Hallo' } })); 38 | 39 | // multilingual glossary example 40 | const glossaryDicts = [{sourceLangCode: 'en', targetLangCode: 'de', entries: new deepl.GlossaryEntries({ entries: { Hello: 'Hallo' } })}]; 41 | const glossaryInfo = await deeplClient.createMultilingualGlossary('My Glossary', glossaryDicts); 42 | ``` 43 | 44 | ### Get a glossary 45 | ```javascript 46 | // monolingual glossary example 47 | const createdGlossary = await deeplClient.createGlossary('My Glossary', 'en', 'de', new deepl.GlossaryEntries({ entries: { Hello: 'Hallo' } })); 48 | const glossaryInfo = await deeplClient.getGlossary(createdGlossary); // GlossaryInfo object 49 | 50 | // multilingual glossary example 51 | const glossaryDicts = [{sourceLangCode: 'en', targetLangCode: 'de', entries: new deepl.GlossaryEntries({ entries: { Hello: 'Hallo' } })}]; 52 | const createdGlossary = await deeplClient.createMultilingualGlossary('My Glossary', glossaryDicts); 53 | const glossaryInfo = await deeplClient.getMultilingualGlossary(createdGlossary); // MultilingualGlossaryInfo object 54 | ``` 55 | 56 | ### Get glossary entries 57 | ```javascript 58 | // monolingual glossary example 59 | const createdGlossary = await deeplClient.createGlossary('My Glossary', 'en', 'de', new deepl.GlossaryEntries({ entries: { Hello: 'Hallo' } })); 60 | const entries = await deeplClient.getGlossaryEntries(createdGlossary); 61 | console.log(entries.toTsv()); // 'hello\thallo' 62 | 63 | // multilingual glossary example 64 | const glossaryDicts = [{sourceLangCode: 'en', targetLangCode: 'de', entries: new deepl.GlossaryEntries({ entries: { Hello: 'Hallo' } })}]; 65 | const createdGlossary = await deeplClient.createMultilingualGlossary('My Glossary', glossaryDicts); 66 | const entriesObj = await deeplClient.getMultilingualGlossaryDictionaryEntries(createdGlossary, 'en', 'de'); 67 | console.log(entriesObj.entries.toTsv()); // 'hello\thallo' 68 | ``` 69 | 70 | ### List and delete glossaries 71 | ```javascript 72 | // monolingual glossary example 73 | const glossaries = await deeplClient.listGlossaries(); 74 | for (const glossary of glossaries) { 75 | if (glossary.name === "Old glossary") { 76 | await deeplClient.deleteGlossary(glossary); 77 | } 78 | } 79 | 80 | // multilingual glossary example 81 | const glossaries = await deeplClient.listMultilingualGlossaries(); 82 | for (const glossary of glossaries) { 83 | if (glossary.name === "Old glossary") { 84 | await deeplClient.deleteMultilingualGlossary(glossary); 85 | } 86 | } 87 | ``` 88 | 89 | 90 | ## 5. New Multilingual Glossary Methods 91 | 92 | In addition to introducing multilingual glossaries, we introduce several new methods that enhance the functionality for managing glossaries. Below are the details for each new method: 93 | 94 | ### Update Multilingual Glossary Dictionary 95 | - **Method**: `async updateMultilingualGlossaryDictionary(glossary: GlossaryId | MultilingualGlossaryInfo, glossaryDict: MultilingualGlossaryDictionaryEntries): Promise<MultilingualGlossaryInfo>` 96 | - **Description**: Use this method to update or create a glossary dictionary's entries 97 | - **Parameters**: 98 | - `glossary`: The ID or `MultilingualGlossaryInfo` of the glossary to update. 99 | - `glossaryDict`: The glossary dictionary including its new entries. 100 | - **Returns**: `MultilingualGlossaryInfo` containing information about the updated glossary. 101 | - **Note**: This method will either update the glossary's entries if they exist for the given glossary dictionary's language pair, or adds any new ones to the dictionary if not. It will also create a new glossary dictionary if one 102 | did not exist for the given language pair. 103 | - **Example**: 104 | ```javascript 105 | const glossaryDicts = [{sourceLangCode: 'en', targetLangCode: 'de', entries: new deepl.GlossaryEntries({ entries: {"artist": "Maler", "hello": "guten tag"} })}]; 106 | const myGlossary = await deeplClient.createMultilingualGlossary('My Glossary', glossaryDicts); 107 | const newDict = {sourceLangCode: 'en', targetLangCode: 'de', entries: new deepl.GlossaryEntries({ entries: {"hello": "hallo", "prize": "Gewinn"} })}; 108 | const updatedGlossary = await deeplClient.updateMultilingualGlossaryDictionary(myGlossary, newDict); 109 | 110 | const entriesObj = await deeplClient.getMultilingualGlossaryDictionaryEntries(createdGlossary, 'en', 'de'); 111 | console.log(entriesObj.entries.toTsv()); // {'artist': 'Maler', 'hello': 'hallo', 'prize': 'Gewinn'} 112 | ``` 113 | 114 | ### Update Multilingual Glossary Dictionary from CSV 115 | - **Method**: `async updateMultilingualGlossaryDictionaryWithCsv(glossary: GlossaryId | MultilingualGlossaryInfo, sourceLanguageCode: string, targetLanguageCode: string, csvContent: string): Promise<MultilingualGlossaryInfo>` 116 | - **Description**: This method allows you to update or create a glossary dictionary using entries in CSV format. 117 | - **Parameters**: 118 | - `glossary`: The ID or `MultilingualGlossaryInfo` of the glossary to update. 119 | - `sourceLanguageCode`: Language of source entries. 120 | - `targetLanguageCode`: Language of target entries. 121 | - `csvContent`: CSV-formatted string containing glossary entries. 122 | - **Returns**: `MultilingualGlossaryInfo` containing information about the updated glossary. 123 | - **Example**: 124 | ```javascript 125 | const fs = require('fs').promises; 126 | const filePath = '/path/to/glossary_file.csv'; 127 | let csvContent = ''; 128 | try { 129 | csvContent = await fs.readFile(filePath, 'utf-8'); 130 | } catch (error) { 131 | console.error(`Error reading file at ${filePath}:`, error); 132 | throw error; 133 | } 134 | const csvContent = await readCsvFile(filePath); 135 | const updatedGlossary = await deeplClient.updateMultilingualGlossaryDictionaryWithCsv('4c81ffb4-2e...', 'en', 'de', csvContent); 136 | ``` 137 | 138 | ### Update Multilingual Glossary Name 139 | - **Method**: `async updateMultilingualGlossaryName(glossary: GlossaryId | MultilingualGlossaryInfo, name: string): Promise<MultilingualGlossaryInfo>` 140 | - **Description**: This method allows you to update the name of an existing glossary. 141 | - **Parameters**: 142 | - `glossary`: The ID or `MultilingualGlossaryInfo` of the glossary to update. 143 | - `name`: The new name for the glossary. 144 | - **Returns**: `MultilingualGlossaryInfo` containing information about the updated glossary. 145 | - **Example**: 146 | ```javascript 147 | const updatedGlossary = deeplGlient.updateMultilingualGlossaryName('4c81ffb4-2e...', 'New Glossary Name') 148 | ``` 149 | 150 | ### Replace a Multilingual Glossary Dictionary 151 | - **Method**: `async replaceMultilingualGlossaryDictionary(glossary: GlossaryId | MultilingualGlossaryInfo, glossaryDict: MultilingualGlossaryDictionaryEntries): Promise<MultilingualGlossaryDictionaryInfo>` 152 | - **Description**: This method replaces the existing glossary dictionary with a new set of entries. 153 | - **Parameters**: 154 | - `glossary`: The ID or `MultilingualGlossaryInfo` of the glossary to update. 155 | - `glossaryDict`: The glossary dictionary that overwrites any existing one. 156 | - **Returns**: `MultilingualGlossaryInfo` containing information about the updated glossary. 157 | - **Note**: Ensure that the new dictionary entries are complete and valid, as this method will completely overwrite the existing entries. It will also create a new glossary dictionary if one did not exist for the given language pair. 158 | - **Example**: 159 | ```javascript 160 | const newGlossaryDict = {sourceLangCode: 'en', targetLangCode: 'de', entries: new deepl.GlossaryEntries({ entries: {"goodbye": "auf Wiedersehen"} })}; 161 | const replacedGlossary = await deeplClient.replaceMultilingualGlossaryDictionary("4c81ffb4-2e...", newGlossaryDict) 162 | ``` 163 | 164 | ### Replace Multilingual Glossary Dictionary from CSV 165 | - **Method**: `async replaceMultilingualGlossaryDictionaryWithCsv(glossary: GlossaryId | MultilingualGlossaryInfo, sourceLanguageCode: string, targetLanguageCode: string, csvContent: string): Promise<MultilingualGlossaryDictionaryInfo>` 166 | - **Description**: This method allows you to replace or create a glossary dictionary using entries in CSV format. 167 | - **Parameters**: 168 | - `glossary`: The ID or `MultilingualGlossaryInfo` of the glossary to update. 169 | - `sourceLanguageCode`: Language of source entries. 170 | - `targetLanguageCode`: Language of target entries. 171 | - `csvContent`: CSV-formatted string containing glossary entries. 172 | - **Returns**: `MultilingualGlossaryInfo` containing information about the updated glossary. 173 | - **Example**: 174 | ```javascript 175 | const fs = require('fs').promises; 176 | const filePath = '/path/to/glossary_file.csv'; 177 | let csvContent = ''; 178 | try { 179 | csvContent = await fs.readFile(filePath, 'utf-8'); 180 | } catch (error) { 181 | console.error(`Error reading file at ${filePath}:`, error); 182 | throw error; 183 | } 184 | const csvContent = await readCsvFile(filePath); 185 | const myCsvGlossary = await deeplClient.replaceMultilingualGlossaryDictionaryWithCsv('4c81ffb4-2e...', 'en', 'de', csvContent); 186 | ``` 187 | 188 | ### Delete a Multilingual Glossary Dictionary 189 | - **Method**: `async deleteMultilingualGlossaryDictionary(glossary: GlossaryId | MultilingualGlossaryInfo, sourceLanguageCode: string, targetLanguageCode: string): Promise<void>` 190 | - **Description**: This method deletes a specified glossary dictionary from a given glossary. 191 | - **Parameters**: 192 | - `glossary`: The ID or `MultilingualGlossaryInfo` of the glossary containing the dictionary to delete. 193 | - `sourceLanguageCode`: The source language of the dictionary to be deleted. 194 | - `targetLanguageCode`: The target language of the dictionary to be deleted. 195 | - **Returns**: None 196 | 197 | - **Migration Note**: Ensure that your application logic correctly identifies the dictionary to delete. Both `sourceLanguageCode` and `targetLanguageCode` must be provided to specify the dictionary. 198 | 199 | - **Example**: 200 | ```javascript 201 | const glossaryDicts = [{sourceLangCode: 'en', targetLangCode: 'de', entries: new deepl.GlossaryEntries({ entries: {'hello': 'hallo'}})}, {sourceLangCode: 'de', targetLangCode: 'en', entries: new deepl.GlossaryEntries({ entries: {'hallo': 'hello'}})}]; 202 | const createdGlossary = await deeplClient.createMultilingualGlossary('My Glossary', glossaryDicts); 203 | 204 | // Delete via specifying the language pair 205 | await deeplClient.deleteMultilingualGlossaryDictionary(createdGlossary, 'de', 'en'); 206 | ``` 207 | --------------------------------------------------------------------------------