diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e3baab --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +*.swp +.classpath +.project +.settings +tags +target +.idea +*.iml +.DS_Store + +# Gradle files +.gradle/* +build/ +out/* + +# compiler output +bin/ \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..185430e --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,90 @@ +# Note: This GitLab CI configuration is used for internal testing, users can ignore it. +include: + - project: '${CI_PROJECT_NAMESPACE}/ci-libs-for-client-libraries' + file: + - '/${CI_PROJECT_NAME}/.gitlab-ci.yml' + +# Global -------------------------- + +image: openjdk:latest + +variables: + GRADLE_OPTS: "-Dorg.gradle.daemon=false" + +stages: + - check + - build + - test + - publish + +before_script: + - GRADLE_USER_HOME="$(pwd)/.gradle" + - export GRADLE_USER_HOME + +# stage: check ---------------------- + +spotless: + stage: check + script: ./gradlew spotlessCheck + +# stage: build ---------------------- + +build: + stage: build + script: + - ./gradlew assemble + artifacts: + paths: + - deepl-java/build/ + +# stage: test ------------------------- + +test: + stage: test + extends: .test + parallel: + matrix: + - DOCKER_IMAGE: "openjdk:18" + - DOCKER_IMAGE: "openjdk:8" + USE_MOCK_SERVER: "use mock server" + - DOCKER_IMAGE: "openjdk:11" + USE_MOCK_SERVER: "use mock server" + - DOCKER_IMAGE: "openjdk:17" + USE_MOCK_SERVER: "use mock server" + - DOCKER_IMAGE: "openjdk:18" + USE_MOCK_SERVER: "use mock server" + - DOCKER_IMAGE: "openjdk:19" + USE_MOCK_SERVER: "use mock server" + - DOCKER_IMAGE: "openjdk:20" + USE_MOCK_SERVER: "use mock server" + image: ${DOCKER_IMAGE} + script: + - > + if [[ ! -z "${USE_MOCK_SERVER}" ]]; then + echo "Using mock server" + export DEEPL_SERVER_URL=http://deepl-mock:3000 + export DEEPL_MOCK_SERVER_PORT=3000 + export DEEPL_PROXY_URL=http://deepl-mock:3001 + export DEEPL_MOCK_PROXY_SERVER_PORT=3001 + fi + - ./gradlew test + artifacts: + paths: + - deepl-java/build/reports/tests/test + reports: + junit: + - deepl-java/build/reports/tests/test/index.html + when: always + +# stage: publish ------------------------- + +publish: + stage: publish + extends: .publish + dependencies: + - build + rules: + - if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/' + script: + - ./gradlew publish + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a664c74 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [0.1.0] - 2022-09-08 +Initial version. + + +[0.1.0]: https://github.com/DeepLcom/deepl-java/releases/tag/v0.1.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5b8c867 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[open-source@deepl.com](mailto:open-source@deepl.com). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..11f4064 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# Contributing guidelines + +* [Code of Conduct](#code-of-conduct) +* [Issues](#issues) +* [Pull Requests](#pull-requests) + +## Code of Conduct + +This project has a [Code of Conduct](CODE_OF_CONDUCT.md) to which all +contributors must adhere when participating in the project. Instances of +abusive, harassing, or otherwise unacceptable behavior may be reported by +contacting [open-source@deepl.com](mailto:open-source@deepl.com) and/or a +project maintainer. + +## Issues + +If you experience problems using the library, or would like to request a new +feature, please open an [issue][issues]. + +Please provide as much context as possible when you open an issue. The +information you provide must be comprehensive enough for us to reproduce the +issue. + +## Pull Requests + +You are welcome to contribute code and/or documentation in order to fix a bug or +to implement a new feature. Before beginning work, you should create an issue +describing the changes you plan to contribute, to avoid wasting or duplicating +effort. We will then let you know whether we would accept the changes. + +Contributions must be licensed under the same license as the project: +[MIT License](LICENSE). + +Currently automated testing is implemented internally at DeepL, however we plan +to implement publicly visible testing soon. + +[issues]: https://www.github.com/DeepLcom/deepl-java/issues diff --git a/README.md b/README.md index 48245c6..5af90bc 100644 --- a/README.md +++ b/README.md @@ -1 +1,381 @@ -# deepl-java +# DeepL Java Library + +[![License: MIT](https://img.shields.io/badge/license-MIT-blueviolet.svg)](https://github.com/DeepLcom/deepl-java/blob/main/LICENSE) + +The [DeepL API][api-docs] is a language translation API that allows other +computer programs to send texts and documents to DeepL's servers and receive +high-quality translations. This opens a whole universe of opportunities for +developers: any translation product you can imagine can now be built on top of +DeepL's best-in-class translation technology. + +The DeepL Java library offers a convenient way for applications written in Java +to interact with the DeepL API. Currently, the library only supports text and +document translation; we intend to add support for glossary management soon. + +## Getting an authentication key + +To use the DeepL Java Library, you'll need an API authentication key. To get a +key, [please create an account here][create-account]. With a DeepL API Free +account you can translate up to 500,000 characters/month for free. + +## Requirements + +Java 1.8 or later. + +## Installation + +### Gradle users + +Add this dependency to your project's build file: + +``` +implementation "com.deepl.api:deepl-java:0.1.0" +``` + +### Maven users + +Add this dependency to your project's POM: + +``` + + com.deepl.api + deepl-java + 0.1.0 + +``` + +## Usage + +Import the package and construct a `Translator`. The first argument is a string +containing your API authentication key as found in your +[DeepL Pro Account][pro-account]. + +Be careful not to expose your key, for example when sharing source code. + +```java +import com.deepl.api.*; + +class Example { + public String basicTranslationExample() { + String authKey = "f63c02c5-f056-..."; // Replace with your key + Translator translator = new Translator(authKey); + TextResult result = + translator.translateText("Hello, world!", null, "fr"); + return result.text; // "Bonjour, le monde !" + } +} +``` + +This example is for demonstration purposes only. In production code, the +authentication key should not be hard-coded, but instead fetched from a +configuration file or environment variable. + +`Translator` accepts additional options, see [Configuration](#configuration) +for more information. + +### Translating text + +To translate text, call `translateText()`. The first argument is a string +containing the text you want to translate, or an iterable of strings if you want +to translate multiple texts. + +`sourceLang` and `targetLang` specify the source and target language codes +respectively. The `sourceLang` is optional, if it is `null` the source language +will be auto-detected. + +Language codes are **case-insensitive** strings according to ISO 639-1, for +example `'de'`, `'fr'`, `'ja''`. Some target languages also include the regional +variant according to ISO 3166-1, for example `'en-US'`, or `'pt-BR'`. The full +list of supported languages is in the [API documentation][api-docs-lang-list]. + +There are additional optional arguments to control translation, see +[Text translation options](#text-translation-options) below. + +`translateText()` returns a `TextResult`, or an array of `TextResult`s +corresponding to your input text(s). `TextResult` has two properties: `text` is +the translated text, and `detectedSourceLanguage` is the detected source +language code. + +```java +class Example { + public void textTranslationExamples() { + // Translate text into a target language, in this case, French: + TextResult result = + translator.translateText("Hello, world!", null, "fr"); + System.out.println(result.text); // "Bonjour, le monde !" + + // Translate multiple texts into British English + TextResult results = + translator.translateText(new String[]{"お元気ですか?", "¿Cómo estás"}, + null, + "en-GB"); + System.out.println(results[0].text); // "How are you?" + System.out.println(results[0].detectedSourceLanguage); // "ja" the language code for Japanese + System.out.println(results[1].text); // "How are you?" + System.out.println(results[1].detectedSourceLanguage); // "es" the language code for Spanish + + // Translate into German with less and more Formality: + System.out.println(translator.translateText("How are you?", + null, + "de", + new TextTranslationOptions().setFormality( + Formality.Less)).text); // 'Wie geht es dir?' + System.out.println(translator.translateText("How are you?", + null, + "de", + new TextTranslationOptions().setFormality( + Formality.More)).text); // 'Wie geht es Ihnen?' + } +} +``` + +#### Text translation options + +In addition to the input text(s) argument, a `translateText()` overload accepts +a `TextTranslationOptions`, with the following setters: + +- `setSentenceSplittingMode()`: specify how input text should be split into + sentences, default: `'on'`. + - `SentenceSplittingMode.All`: input text will be split into sentences using + both newlines and punctuation. + - `SentenceSplittingMode.Off`: input text will not be split into sentences. + Use this for applications where each input text contains only one + sentence. + - `SentenceSplittingMode.NoNewlines`: input text will be split into + sentences using punctuation but not newlines. +- `setPreserveFormatting()`: controls automatic-formatting-correction. Set to + `True` to prevent automatic-correction of formatting, default: `false`. +- `setFormality()`: controls whether translations should lean toward informal or + formal language. This option is only available for some target languages, see + [Listing available languages](#listing-available-languages). + - `Formality.Less`: use informal language. + - `Formality.More`: use formal, more polite language. +- `setGlossaryId()`: specifies a glossary to use with translation, as a string + containing the glossary ID. +- `setTagHandling()`: type of tags to parse before translation, options are + `"html"` and `"xml"`. + +The following options are only used if `setTagHandling()` is set to `'xml'`: + +- `setOutlineDetection()`: specify `false` to disable automatic tag detection, + default is `true`. +- `setSplittingTags()`: list of XML tags that should be used to split text into + sentences. Tags may be specified as an array of strings (`['tag1', 'tag2']`), + or a comma-separated list of strings (`'tag1,tag2'`). The default is an empty + list. +- `setNonSplittingTags()`: list of XML tags that should not be used to split + text into sentences. Format and default are the same as for splitting tags. +- `setIgnoreTags()`: list of XML tags that containing content that should not be + translated. Format and default are the same as for splitting tags. + +For a detailed explanation of the XML handling options, see the +[API documentation][api-docs-xml-handling]. + +### Translating documents + +To translate documents, call `translateDocument()` File objects. The first and +second arguments correspond to the input and output files respectively. + +Just as for the `translateText()` function, the `sourceLang` and +`targetLang` arguments specify the source and target language codes. + +There are additional optional arguments to control translation, see +[Document translation options](#document-translation-options) below. + +```java +class Example { + public void documentTranslationExamples() { + // Translate a formal document from English to German + File inputFile = new File("/path/to/Instruction Manual.docx"); + File outputFile = new File("/path/to/Bedienungsanleitung.docx"); + try { + translator.translateDocument(inputFile, outputFile, "en", "de"); + } catch (DocumentTranslationException exception) { + // If an error occurs during document translation after the document was + // already uploaded, a DocumentTranslationException is thrown. The + // document_handle property contains the document handle that may be used to + // later retrieve the document from the server, or contact DeepL support. + DocumentHandle handle = exception.getHandle(); + System.out.write(String.format( + "Error after uploading %s, document handle: id: %s key: %s", + exception.getMessage(), + handle.getDocumentId(), + handle.getDocumentKey())); + } + } +} +``` + +`translateDocument()` is a convenience function that wraps multiple API calls: +uploading, polling status until the translation is complete, and downloading. If +your application needs to execute these steps individually, you can instead use +the following functions directly: + +- `translateDocumentUpload()`, +- `translateDocumentGetStatus()` (or + `translateDocumentWaitUntilDone()`), and +- `translateDocumentDownload()` + +#### Document translation options + +In addition to the input file, output file, `sourceLang` and `targetLang` +arguments, the available `translateDocument()` setters are: + +- `setFormality()`: same as in [Text translation options](#text-translation-options). +- `setGlossaryId()`: same as in [Text translation options](#text-translation-options). + +### Checking account usage + +To check account usage, use the `getUsage()` function. + +The returned `Usage` object contains three usage subtypes: `character`, +`document` and `teamDocument`. Depending on your account type, some usage +subtypes may be `null`. For API accounts: + +- `usage.character` is non-`null`, +- `usage.document` and `usage.teamDocument` are `null`. + +Each usage subtype (if valid) has `count` and `limit` properties giving the +amount used and maximum amount respectively, and the `limit_reached` property +that checks if the usage has reached the limit. The top level `Usage` object has +the `any_limit_reached` property to check all usage subtypes. + +```java +class Example { + public void getUsageExample() { + Usage usage = translator.getUsage(); + if (usage.anyLimitReached()) { + System.out.println("Translation limit reached."); + } + if (usage.character != null) { + System.out.println(String.format("Character usage: %d of %d", + usage.character.count, + usage.character.count)); + } + if (usage.document != null) { + System.out.println(String.format("Document usage: %d of %d", + usage.document.count, + usage.document.limit)); + } + } +} +``` + +### Listing available languages + +You can request the list of languages supported by DeepL for text and documents +using the `getSourceLanguages()` and `getTargetLanguages()` functions. They both +return a list of `Language` objects. + +The `name` property gives the name of the language in English, and the `code` +property gives the language code. The `supportsFormality` property only appears +for target languages, and indicates whether the target language supports the +optional `formality` parameter. + +```java +class Example { + public void getLanguagesExample() { + Language[] sourceLanguages = translator.getSourceLanguages(); + Language[] targetLanguages = translator.getTargetLanguages(); + System.out.println("Source languages:"); + for (Language language : sourceLanguages) { + System.out.println(String.format("%s (%s)", + language.name, + language.code)); // Example: "German (de)" + + } + + System.out.println("Target languages:"); + for (Language language : targetLanguages) { + if (language.supportsFormality) { + System.out.println(String.format("%s (%s) supports formality", + language.name, + language.code)); // Example: "Italian (it) supports formality" + + } else { + System.out.println(String.format("%s (%s)", + language.name, + language.code)); // Example: "Lithuanian (lt)" + } + } + } +} +``` + +### Exceptions + +All module functions may raise `DeepLException` or one of its subclasses. If +invalid arguments are provided, they may raise the standard exceptions +`IllegalArgumentException`. + +### Configuration + +The `Translator` constructor accepts `TranslatorOptions` as a second argument, +for example: + +```java +class Example { + public void configurationExample() { + TranslatorOptions options = + new TranslatorOptions().setMaxRetries(1).setTimeout(Duration.ofSeconds( + 1)); + Translator translator = new Translator(authKey, options); + } +} +``` + +The available options setters are: + +- `setMaxRetries()`: maximum number of failed HTTP requests to retry, the + default is 5. Note: only failures due to transient conditions are retried e.g. + timeouts or temporary server overload. +- `setTimeout()`: connection timeout for each HTTP request. +- `setProxy()`: provide details about a proxy to use for all HTTP requests to + DeepL. +- `setHeaders()`: additional HTTP headers to attach to all requests. +- `setServerUrl()`: base URL for DeepL API, may be overridden for testing + purposes. By default, the correct DeepL API (Free or Pro) is automatically + selected. + +## Issues + +If you experience problems using the library, or would like to request a new +feature, please open an [issue][issues]. + +## Development + +We welcome Pull Requests, please read the +[contributing guidelines](CONTRIBUTING.md). + +### Tests + +Execute the tests using `./gradlew test`. The tests communicate with the DeepL +API using the auth key defined by the `DEEPL_AUTH_KEY` environment variable. + +Be aware that the tests make DeepL API requests that contribute toward your API +usage. + +The test suite may instead be configured to communicate with the mock-server +provided by [deepl-mock][deepl-mock]. Although most test cases work for either, +some test cases work only with the DeepL API or the mock-server and will be +otherwise skipped. The test cases that require the mock-server trigger server +errors and test the client error-handling. To execute the tests using +deepl-mock, run it in another terminal while executing the tests. Execute the +tests using `./gradlew test` with the `DEEPL_MOCK_SERVER_PORT` and +`DEEPL_SERVER_URL` environment variables defined referring to the mock-server. + +[api-docs]: https://www.deepl.com/docs-api?utm_source=github&utm_medium=github-java-readme + +[api-docs-xml-handling]: https://www.deepl.com/docs-api/handling-xml/?utm_source=github&utm_medium=github-java-readme + +[api-docs-lang-list]: https://www.deepl.com/docs-api/translating-text/?utm_source=github&utm_medium=github-java-readme + +[api-docs-glossary-lang-list]: https://www.deepl.com/docs-api/managing-glossaries/?utm_source=github&utm_medium=github-java-readme + +[create-account]: https://www.deepl.com/pro?utm_source=github&utm_medium=github-java-readme#developer + +[deepl-mock]: https://www.github.com/DeepLcom/deepl-mock + +[issues]: https://www.github.com/DeepLcom/deepl-java/issues + +[pro-account]: https://www.deepl.com/pro-account/?utm_source=github&utm_medium=github-java-readme diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e0de362 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,58 @@ +To report security concerns or vulnerabilities within deepl-java, please email +us at [security@deepl.com](mailto:security@deepl.com). + +You can send us PGP-encrypted email using the following PGP public key: + +``` +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBF7WSmABEADzRUp22VY7bVfUWScKLi9o8BRSEL4u3aPn9WOQoRLQH0j3dNNQ +FQlwTPn/Ez6qreEl8mX0aE+eLCEykXqsrU/UaTSTslF+H6UQyuGLXkRm8Lblt93I +OEhL069fC7rm+/zJq72+hishBF8DXqa+WtFd8VfK3i211vRhU/teKeAKT0xiuN/5 +EQl1Bn7jR7mmQtNbPBhCsAlaC/tNUQ3Lyj6LYnnco7ums5Q/gCvfs2HM3mXJyvnG +1MC2IrECPowTt04W3V1uXuMcm766orTG/AmtBIbPmOzao4sfqwRVHGvc8zcr1az9 +0nVyEJXx1eUVRDU1GAQuMjEkGgwvTd+nt6sHpn8C+9hMYJhon9veDSupViBuvNRC +p1PnpSLYYy7tA7DPXMhP3cMXe+Z4bYzgwi3xjOwh6SDyB4OFIxtGyuMrLGfZnd6A +hDH4H9zHPpciD5MxcOaqlKdgABQALvc6MvJ1Guf1ckGTbfHz1brtR1LPMK8rrnNu +kwQzgkogYV6YAnt8LPXMPa2Vgy8TAiby7GPaATPeSWdNHtkuYGhWNVbnb60kEWiJ +/RgHFZYfRT1dEcKoQEcDJ7AV14urEFIAfmhlsT8h7iJYUQMa45BakUubi3aWwcme +ya+5WXvp2xU14VMfrscApA0e1v0VcTNVwlKambs/lwims0/xiSaXJS6gVwARAQAB +tCNEZWVwTCBTZWN1cml0eSA8c2VjdXJpdHlAZGVlcGwuY29tPokCTgQTAQgAOBYh +BGvTAPE3gtThLDZ9+ey96Y7yK41BBQJe1kpgAhsDBQsJCAcCBhUKCQgLAgQWAgMB +Ah4BAheAAAoJEOy96Y7yK41BHVIP/04R08g4N32c47edY6z3sl3DAf+/6UI4Bc4S +Jg5L4JcfrsKaDd55plps8nj31VXrxVBO0NrO6HLC50SXbYVrANyo0occ2mIoU8c2 +tNbYCUmJ3QjlUwDjHWlMV2J9FcfZkv7z+2TDY6DF8MKqCMi8j7Pnj0hlY0JytciH +SGES1q8+//8tG9z6b6vvxBFfJI+iNXvcbn6uU1WRvGoBqq2A13fXuwTXiNNphsvu +kHqBHSxnf/EAmcmBX0tm6yaWDdwy+rrcDNwXiqqvK6DFWEE7+/9t2FhlgzvuCOfx +dQVMZL8WH2rr6OPQLDgtGxEUFmD+srmqbVn5NKdY6lQ/BEaraozDkuqJEb0/L/kb +Dv+buz8rmKze0XPlrt1XTQ5ZDQp8AMAaPp1UsizVhasZgxxuUa+g5mMbJr7TSNJN +CIqidnh1MEyIr3IccOAr2F51hn6keKIdVnO4dWrWNMTfk00dw3fPGFhNTniITTF2 +s3oJ8cy2NMNkVMP5XL3bulpgkKN+hXa4IHkTfWRv7hfYJ/3i3yTRNRjYGRoVp7eM +iADumKaZy5Szl458txuI+p9DGAEvkSJoF7ptwedSvVZ/FZukS5mwYisRV9shzsXF +3jpcGZ1B3qS68r9ySqnPEWR6oT8p63fpMNVMjz5r4YEbvU0A62OhUk52drLM6SgC +mdOZcmnHuQINBF7WSmABEADc6L/wSexm4l1GWZSQpJ35ldlu7jjWQGguQeeG2900 +aEI3UcftMCWg+apwf4h4Yj2YjzUncYAM6RenGvXgZUYQe3OHb8uqpkSmYHUdB/Uq +I4NPO3e8RMDo9YohPKCpZ7jV70X8F9GOUkUgfp29CjrMOYgSLwkSyWotsQ9KtkEH +Sx/h+gviIERe0dkiN9lCsReNigoWLleH4qBSZGPxqF4tzANJ6D2tnAv+6KUQvho3 +CdijBiia4o16p9M0altSqsZCEX1Y5BKmWIh9fvvS2uB7SdzS0gcASzlekMGCjG10 +dNji+uSNdHExlbl0kUpEL1TuY2hxPBa6lc1hckI3dGng0jIFlio4s8DG3Utmrj3C +KQFxnjqtO+uaJ8HdNo8ObtEp/v9TpsHWUchBTrBP4XN5KwqkljF8XVBA6ceh8H38 +7/RVWRcWp6h30ROm1DTnAGxJk02fbjpnEO0EvudxKTlnAJXV6z+Tm3yYaR4gQYa3 +/zfLZgz0z0MqNUsGephZGPzfUX7Lsz6HGUoo7I1KST6xD2QodJYOhHIEOgsqskk+ +cgeXp45X5JLlCQaBLQoL8ut6CTcop1/6U+JZtrm6DdXTZfq57sqfDI+gkG8WljRY +yhsCL+xWiwDjtt/8kpk+W75EQmwPuctoS85Rm6hEpffewdQtb2XCEWpbta6hE1r1 +kQARAQABiQI2BBgBCAAgFiEEa9MA8TeC1OEsNn357L3pjvIrjUEFAl7WSmACGwwA +CgkQ7L3pjvIrjUHFvg/9GnIW9SM/nYJpi1xZVWWGwQ+/kTceD50bv8kyvNaia/9m +HG6n83xHNTRBYnt8NtTqHvW0y20Cp3gUs2WxboDgCIb3+srI2ipwiaDJcq+rVr0f +XkCe5MryioKRbTFQ8OgvKh9GK/tYtqZakn7Q9596ajUjHOQV1+Uw/jywLYRlcbqI +zbxyNVWitxPs3Z7jUDAvhPOIOmhLFc+QxSYrs1W4ZEGnZ3+9utqzlEiMusy9Rq0T +/W/wrG6SckebjhrwWZJmy/hkW6V6LUX4++vCVV5+zwsvgEortCV8bhvLfqQDr/WN +fnmbNZtXJbyhTYbcYReOLeKidxO2lZEemnX6iOt5xCdoMcYU23xDT9+tE7Eh6Nfw +einZemBwfku5vxxPF73pOoQUCRq9tgvUrEq+3BqkqidhnFUOPi0J5726q1PBG65x +5o+SQyvB3NA3al3mEH65z3V3/g0UHnhGcEMwVOXBkffgdKNhWYw59qhSVQnkiq0U +MG10g/RL7VdiISAFPTDmKWUaEDYosinKqOMHwcaVdJq9ssvPf89et6yP/ZkbLIHs +2y3oiPonh2RMxi2OedlDz+Jp/A2o3qHmwNvBx/meGB0praGUonFVZTAA1EMS39Bi +NhG/L8giTyzA0mMkTJAPXtUVlRe5rEjORgYJsgRqZxEfpsJC9OkvYS4ayO0eCEs= +=jVHt +-----END PGP PUBLIC KEY BLOCK----- +``` \ No newline at end of file diff --git a/deepl-java/build.gradle.kts b/deepl-java/build.gradle.kts new file mode 100644 index 0000000..13c4cc0 --- /dev/null +++ b/deepl-java/build.gradle.kts @@ -0,0 +1,112 @@ +plugins { + `java-library` + `maven-publish` + signing + id("com.diffplug.spotless") version "6.8.0" +} + +group = "com.deepl.api" +version = "0.1.0" + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.jetbrains:annotations:20.1.0") + testImplementation("org.junit.jupiter:junit-jupiter:5.8.1") + + api("org.apache.commons:commons-math3:3.6.1") + +// implementation("com.google.guava:guava:30.1.1-jre") + implementation("com.google.code.gson:gson:2.9.0") +} + + +tasks.named("test") { + useJUnitPlatform() +} + +spotless { + java { + googleJavaFormat("1.7") + removeUnusedImports() + } +} + +tasks.register("sourcesJar") { + archiveClassifier.set("sources") + from(sourceSets.main.get().allJava) +} + +tasks.register("javadocJar") { + archiveClassifier.set("javadoc") + from(tasks.javadoc.get().destinationDir) +} + +publishing { + repositories { + maven { + val mavenUploadUsername: String? by project + val mavenUploadPassword: String? by project + name = "MavenCentral" + val releasesRepoUrl = "https://oss.sonatype.org/service/local/staging/deploy/maven2" + val snapshotsRepoUrl = "https://oss.sonatype.org/content/repositories/snapshots" + url = uri(if (version.toString().endsWith("SNAPSHOT")) snapshotsRepoUrl else releasesRepoUrl) + credentials { + username = mavenUploadUsername + password = mavenUploadPassword + } + } + } + publications { + create("mavenJava") { + from(components["java"]) + pom { + name.set("deepl-java") + description.set("DeepL API Java Client Library") + url.set("https://www.github.com/DeepLcom/deepl-java") + properties.set(mapOf( + "java.version" to "1.8", + "project.build.sourceEncoding" to "UTF-8", + "project.reporting.outputEncoding" to "UTF-8" + )) + licenses { + license { + name.set("MIT License") + url.set("https://www.opensource.org/licenses/mit-license.php") + } + } + developers { + developer { + id.set("deepl") + name.set("DeepL SE") + email.set("open-source@deepl.com") + } + } + organization { + name.set("DeepL SE") + url.set("https://www.deepl.com") + } + scm { + connection.set("scm:git:git://github.com/DeepLcom/deepl-java.git") + developerConnection.set("scm:git:ssh://github.com/DeepLcom/deepl-java.git") + url.set("https://www.github.com/DeepLcom/deepl-java") + } + } + } + } +} + +signing { + val signingKey: String? by project + val signingPassword: String? by project + useInMemoryPgpKeys(signingKey, signingPassword) + sign(publishing.publications["mavenJava"]) +} + diff --git a/deepl-java/src/main/java/com/deepl/api/AuthorizationException.java b/deepl-java/src/main/java/com/deepl/api/AuthorizationException.java new file mode 100644 index 0000000..86e095a --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/AuthorizationException.java @@ -0,0 +1,11 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +/** Exception thrown when the specified authentication key was invalid. */ +public class AuthorizationException extends DeepLException { + public AuthorizationException(String message) { + super(message); + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/ConnectionException.java b/deepl-java/src/main/java/com/deepl/api/ConnectionException.java new file mode 100644 index 0000000..1e41a19 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/ConnectionException.java @@ -0,0 +1,22 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +/** Exception thrown when a connection error occurs while accessing the DeepL API. */ +public class ConnectionException extends DeepLException { + private final boolean shouldRetry; + + public ConnectionException(String message, boolean shouldRetry, Throwable cause) { + super(message, cause); + this.shouldRetry = shouldRetry; + } + + /** + * Returns true if this exception occurred due to transient condition and the request + * should be retried, otherwise false. + */ + public boolean getShouldRetry() { + return shouldRetry; + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/DeepLException.java b/deepl-java/src/main/java/com/deepl/api/DeepLException.java new file mode 100644 index 0000000..2f93893 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/DeepLException.java @@ -0,0 +1,15 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +/** Base class for all exceptions thrown by this library. */ +public class DeepLException extends Exception { + public DeepLException(String message) { + super(message); + } + + public DeepLException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/DocumentHandle.java b/deepl-java/src/main/java/com/deepl/api/DocumentHandle.java new file mode 100644 index 0000000..1c7279d --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/DocumentHandle.java @@ -0,0 +1,34 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +import com.google.gson.annotations.SerializedName; + +/** + * Handle to an in-progress document translation. + * + * @see Translator#translateDocumentStatus(DocumentHandle) + */ +public class DocumentHandle { + @SerializedName(value = "document_id") + private final String documentId; + + @SerializedName(value = "document_key") + private final String documentKey; + + public DocumentHandle(String documentId, String documentKey) { + this.documentId = documentId; + this.documentKey = documentKey; + } + + /** Get the ID of associated document request. */ + public String getDocumentId() { + return documentId; + } + + /** Get the key of associated document request. */ + public String getDocumentKey() { + return documentKey; + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/DocumentNotReadyException.java b/deepl-java/src/main/java/com/deepl/api/DocumentNotReadyException.java new file mode 100644 index 0000000..d961098 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/DocumentNotReadyException.java @@ -0,0 +1,11 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +/** Exception thrown when attempting to download a translated document before it is ready. */ +public class DocumentNotReadyException extends DeepLException { + public DocumentNotReadyException(String message) { + super(message); + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/DocumentStatus.java b/deepl-java/src/main/java/com/deepl/api/DocumentStatus.java new file mode 100644 index 0000000..4d529c0 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/DocumentStatus.java @@ -0,0 +1,104 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +import com.google.gson.annotations.SerializedName; +import org.jetbrains.annotations.Nullable; + +/** Status of an in-progress document translation. */ +public class DocumentStatus { + @SerializedName(value = "document_id") + private final String documentId; + + @SerializedName(value = "status") + private final StatusCode status; + + @SerializedName(value = "billed_characters") + private final @Nullable Long billedCharacters; + + @SerializedName(value = "seconds_remaining") + private final @Nullable Long secondsRemaining; + + @SerializedName(value = "error_message") + private final @Nullable String errorMessage; + + /** Status code indicating status of the document translation. */ + public enum StatusCode { + /** Document translation has not yet started, but will begin soon. */ + @SerializedName("queued") + Queued, + /** Document translation is in progress. */ + @SerializedName("translating") + Translating, + /** + * Document translation completed successfully, and the translated document may be downloaded. + */ + @SerializedName("done") + Done, + /** An error occurred during document translation. */ + @SerializedName("error") + Error, + } + + public DocumentStatus( + String documentId, + StatusCode status, + @Nullable Long billedCharacters, + @Nullable Long secondsRemaining, + @Nullable String errorMessage) { + this.documentId = documentId; + this.status = status; + this.billedCharacters = billedCharacters; + this.secondsRemaining = secondsRemaining; + this.errorMessage = errorMessage; + } + + /** @return Document ID of the associated document. */ + public String getDocumentId() { + return documentId; + } + + /** @return Status of the document translation. */ + public StatusCode getStatus() { + return status; + } + + /** + * @return true if no error has occurred during document translation, otherwise + * false. + */ + public boolean ok() { + return status != null && status != StatusCode.Error; + } + + /** + * @return true if document translation has completed successfully, otherwise + * false. + */ + public boolean done() { + return status != null && status == StatusCode.Done; + } + + /** + * @return Number of seconds remaining until translation is complete if available, otherwise + * null. Only available while document is in translating state. + */ + public @Nullable Long getSecondsRemaining() { + return secondsRemaining; + } + + /** + * @return Number of characters billed for the translation of this document if available, + * otherwise null. Only available after document translation is finished and the + * status is {@link StatusCode#Done}, otherwise null. + */ + public @Nullable Long getBilledCharacters() { + return billedCharacters; + } + + /** @return Short description of the error if available, otherwise null. */ + public @Nullable String getErrorMessage() { + return errorMessage; + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/DocumentTranslationException.java b/deepl-java/src/main/java/com/deepl/api/DocumentTranslationException.java new file mode 100644 index 0000000..6fb39be --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/DocumentTranslationException.java @@ -0,0 +1,30 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +import org.jetbrains.annotations.Nullable; + +/** + * Exception thrown when an error occurs during {@link Translator#translateDocument}. If the error + * occurs after the document was successfully uploaded, the {@link DocumentHandle} for the + * associated document is included, to allow later retrieval of the document. + */ +public class DocumentTranslationException extends DeepLException { + + private final @Nullable DocumentHandle handle; + + public DocumentTranslationException( + String message, Throwable throwable, @Nullable DocumentHandle handle) { + super(message, throwable); + this.handle = handle; + } + + /** + * Get the handle to the in-progress document translation, or null if an error + * occurred before uploading the document. + */ + public @Nullable DocumentHandle getHandle() { + return handle; + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/DocumentTranslationOptions.java b/deepl-java/src/main/java/com/deepl/api/DocumentTranslationOptions.java new file mode 100644 index 0000000..ac5d6a6 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/DocumentTranslationOptions.java @@ -0,0 +1,51 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +/** + * Options to control document translation behaviour. These options may be provided to {@link + * Translator#translateDocument} overloads. + * + *

All properties have corresponding setters in fluent-style, so the following is possible: + * + * DocumentTranslationOptions options = new DocumentTranslationOptions() + * .setFormality(Formality.Less).setGlossaryId("f63c02c5-f056-.."); + * + */ +public class DocumentTranslationOptions { + private Formality formality; + private String glossaryId; + + /** + * Sets whether translations should lean toward formal or informal language. This option is only + * applicable for target languages that support the formality option. By default, this value is + * null and translations use the default formality. + * + * @see Language#getSupportsFormality() + * @see Formality + */ + public DocumentTranslationOptions setFormality(Formality formality) { + this.formality = formality; + return this; + } + + /** + * Sets the ID of a glossary to use with the translation. By default, this value is + * null and no glossary is used. + */ + public DocumentTranslationOptions setGlossaryId(String glossaryId) { + this.glossaryId = glossaryId; + return this; + } + + /** Gets the current formality setting. */ + public Formality getFormality() { + return formality; + } + + /** Gets the current glossary ID. */ + public String getGlossaryId() { + return glossaryId; + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/Formality.java b/deepl-java/src/main/java/com/deepl/api/Formality.java new file mode 100644 index 0000000..c3ed4e9 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/Formality.java @@ -0,0 +1,16 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +/** Desired level of formality for translation. */ +public enum Formality { + /** Standard level of formality. */ + Default, + + /** Less formality, i.e. more informal. */ + Less, + + /** Increased formality. */ + More, +} diff --git a/deepl-java/src/main/java/com/deepl/api/HttpClientWrapper.java b/deepl-java/src/main/java/com/deepl/api/HttpClientWrapper.java new file mode 100644 index 0000000..2c33c20 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/HttpClientWrapper.java @@ -0,0 +1,142 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +import com.deepl.api.http.*; +import com.deepl.api.utils.*; +import java.io.*; +import java.net.*; +import java.time.*; +import java.util.*; +import org.jetbrains.annotations.*; + +/** Helper class providing functions to make HTTP requests and retry with exponential-backoff. */ +class HttpClientWrapper { + private static final String CONTENT_TYPE = "Content-Type"; + private static final String POST = "POST"; + private final String serverUrl; + private final Map headers; + private final Duration minTimeout; + private final @Nullable Proxy proxy; + private final int maxRetries; + + public HttpClientWrapper( + String serverUrl, + Map headers, + Duration minTimeout, + @Nullable Proxy proxy, + int maxRetries) { + this.serverUrl = serverUrl; + this.headers = headers; + this.minTimeout = minTimeout; + this.proxy = proxy; + this.maxRetries = maxRetries; + } + + public HttpResponse sendRequestWithBackoff(String relativeUrl) + throws InterruptedException, DeepLException { + return sendRequestWithBackoff(POST, relativeUrl, null).toStringResponse(); + } + + public HttpResponse sendRequestWithBackoff( + String relativeUrl, @Nullable Iterable> params) + throws InterruptedException, DeepLException { + HttpContent content = HttpContent.buildFormURLEncodedContent(params); + return sendRequestWithBackoff(POST, relativeUrl, content).toStringResponse(); + } + + public HttpResponseStream downloadWithBackoff( + String relativeUrl, @Nullable Iterable> params) + throws InterruptedException, DeepLException { + HttpContent content = HttpContent.buildFormURLEncodedContent(params); + return sendRequestWithBackoff(POST, relativeUrl, content); + } + + public HttpResponse uploadWithBackoff( + String relativeUrl, + @Nullable Iterable> params, + String fileName, + InputStream inputStream) + throws InterruptedException, DeepLException { + ArrayList> fields = new ArrayList<>(); + fields.add(new KeyValuePair<>("file", new NamedStream(fileName, inputStream))); + if (params != null) { + params.forEach( + (KeyValuePair entry) -> { + fields.add(new KeyValuePair<>(entry.getKey(), entry.getValue())); + }); + } + HttpContent content; + try { + content = HttpContent.buildMultipartFormDataContent(fields); + } catch (Exception e) { + throw new DeepLException("Failed building request", e); + } + return sendRequestWithBackoff(POST, relativeUrl, content).toStringResponse(); + } + + // Sends a request with exponential backoff + private HttpResponseStream sendRequestWithBackoff( + String method, String relativeUrl, HttpContent content) + throws InterruptedException, DeepLException { + BackoffTimer backoffTimer = new BackoffTimer(this.minTimeout); + while (true) { + try { + HttpResponseStream response = + sendRequest(method, serverUrl + relativeUrl, backoffTimer.getTimeoutMillis(), content); + if (backoffTimer.getNumRetries() >= this.maxRetries) { + return response; + } else if (response.getCode() != 429 + && (response.getCode() < 500 || response.getCode() == 503)) { + return response; + } + response.close(); + } catch (ConnectionException exception) { + if (!exception.getShouldRetry() || backoffTimer.getNumRetries() >= this.maxRetries) { + throw exception; + } + } + backoffTimer.sleepUntilRetry(); + } + } + + private HttpResponseStream sendRequest( + String method, String urlString, long timeoutMs, HttpContent content) + throws ConnectionException { + try { + URL url = new URL(urlString); + HttpURLConnection connection = + (HttpURLConnection) (proxy != null ? url.openConnection(proxy) : url.openConnection()); + + connection.setRequestMethod(method); + connection.setConnectTimeout((int) timeoutMs); + connection.setReadTimeout((int) timeoutMs); + connection.setUseCaches(false); + + for (Map.Entry entry : this.headers.entrySet()) { + connection.setRequestProperty(entry.getKey(), entry.getValue()); + } + + if (content != null) { + connection.setDoOutput(true); + connection.setRequestProperty(CONTENT_TYPE, content.getContentType()); + + try (OutputStream output = connection.getOutputStream()) { + output.write(content.getContent()); + } + } + + int responseCode = connection.getResponseCode(); + InputStream responseStream = + (responseCode >= 200 && responseCode < 400) + ? connection.getInputStream() + : connection.getErrorStream(); + return new HttpResponseStream(responseCode, responseStream); + } catch (SocketTimeoutException e) { + throw new ConnectionException(e.getMessage(), true, e); + } catch (RuntimeException | IOException e) { + throw new ConnectionException(e.getMessage(), false, e); + } + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/Language.java b/deepl-java/src/main/java/com/deepl/api/Language.java new file mode 100644 index 0000000..748e458 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/Language.java @@ -0,0 +1,57 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +import org.jetbrains.annotations.Nullable; + +/** + * A language supported by DeepL translation. The {@link Translator} class provides functions to + * retrieve the available source and target languages. + * + * @see Translator#getSourceLanguages() + * @see Translator#getTargetLanguages() + */ +public class Language { + private final String name; + private final String code; + private final @Nullable Boolean supportsFormality; + + /** + * Initializes a new Language object. + * + * @param name The name of the language in English. + * @param code The language code. + * @param supportsFormality true for a target language that supports the {@link + * TextTranslationOptions#setFormality} option for translations, false for other + * target languages, or null for source languages. + */ + public Language(String name, String code, @Nullable Boolean supportsFormality) { + this.name = name; + this.code = LanguageCode.standardize(code); + this.supportsFormality = supportsFormality; + } + + /** @return The name of the language in English, for example "Italian" or "Romanian". */ + public String getName() { + return name; + } + + /** + * @return The language code, for example "it", "ro" or "en-US". Language codes follow ISO 639-1 + * with an optional regional code from ISO 3166-1. + */ + public String getCode() { + return code; + } + + /** + * @return true if this language is a target language that supports the {@link + * TextTranslationOptions#setFormality} option for translations, false if this + * language is a target language that does not support formality, or null if this + * language is a source language. + */ + public @Nullable Boolean getSupportsFormality() { + return supportsFormality; + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/LanguageCode.java b/deepl-java/src/main/java/com/deepl/api/LanguageCode.java new file mode 100644 index 0000000..686f0c4 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/LanguageCode.java @@ -0,0 +1,134 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +/** + * Language codes for the languages currently supported by DeepL translation. New languages may be + * added in the future; to retrieve the currently supported languages use {@link + * Translator#getSourceLanguages()} and {@link Translator#getTargetLanguages()}. + */ +public class LanguageCode { + /** Bulgarian language code, may be used as source or target language. */ + public static final String Bulgarian = "bg"; + + /** Czech language code, may be used as source or target language. */ + public static final String Czech = "cs"; + + /** Danish language code, may be used as source or target language. */ + public static final String Danish = "da"; + + /** German language code, may be used as source or target language. */ + public static final String German = "de"; + + /** Greek language code, may be used as source or target language. */ + public static final String Greek = "el"; + + /** + * English language code, may only be used as a source language. In input texts, this language + * code supports all English variants. + */ + public static final String English = "en"; + + /** British English language code, may only be used as a target language. */ + public static final String EnglishBritish = "en-GB"; + + /** American English language code, may only be used as a target language. */ + public static final String EnglishAmerican = "en-US"; + + /** Spanish language code, may be used as source or target language. */ + public static final String Spanish = "es"; + + /** Estonian language code, may be used as source or target language. */ + public static final String Estonian = "et"; + + /** Finnish language code, may be used as source or target language. */ + public static final String Finnish = "fi"; + + /** French language code, may be used as source or target language. */ + public static final String French = "fr"; + + /** Hungarian language code, may be used as source or target language. */ + public static final String Hungarian = "hu"; + + /** Indonesian language code, may be used as source or target language. */ + public static final String Indonesian = "id"; + + /** Italian language code, may be used as source or target language. */ + public static final String Italian = "it"; + + /** Japanese language code, may be used as source or target language. */ + public static final String Japanese = "ja"; + + /** Lithuanian language code, may be used as source or target language. */ + public static final String Lithuanian = "lt"; + + /** Latvian language code, may be used as source or target language. */ + public static final String Latvian = "lv"; + + /** Dutch language code, may be used as source or target language. */ + public static final String Dutch = "nl"; + + /** Polish language code, may be used as source or target language. */ + public static final String Polish = "pl"; + + /** + * Portuguese language code, may only be used as a source language. In input texts, this language + * code supports all Portuguese variants. + */ + public static final String Portuguese = "pt"; + + /** Brazilian Portuguese language code, may only be used as a target language. */ + public static final String PortugueseBrazilian = "pt-BR"; + + /** European Portuguese language code, may only be used as a target language. */ + public static final String PortugueseEuropean = "pt-PT"; + + /** Romanian language code, may be used as source or target language. */ + public static final String Romanian = "ro"; + + /** Russian language code, may be used as source or target language. */ + public static final String Russian = "ru"; + + /** Slovak language code, may be used as source or target language. */ + public static final String Slovak = "sk"; + + /** Slovenian language code, may be used as source or target language. */ + public static final String Slovenian = "sl"; + + /** Swedish language code, may be used as source or target language. */ + public static final String Swedish = "sv"; + + /** Turkish language code, may be used as source or target language. */ + public static final String Turkish = "tr"; + + /** Chinese language code, may be used as source or target language. */ + public static final String Chinese = "zh"; + + /** + * Removes the regional variant (if any) from the given language code. + * + * @param langCode Language code possibly containing a regional variant. + * @return The language code without a regional variant. + */ + public static String removeRegionalVariant(String langCode) { + String[] parts = langCode.split("-", 2); + return parts[0].toLowerCase(); + } + + /** + * Changes the upper- and lower-casing of the given language code to match ISO 639-1 with an + * optional regional code from ISO 3166-1. + * + * @param langCode String containing language code to standardize. + * @return String containing the standardized language code. + */ + public static String standardize(String langCode) { + String[] parts = langCode.split("-", 2); + if (parts.length == 1) { + return parts[0].toLowerCase(); + } else { + return parts[0].toLowerCase() + "-" + parts[1].toUpperCase(); + } + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/LanguageType.java b/deepl-java/src/main/java/com/deepl/api/LanguageType.java new file mode 100644 index 0000000..cfe6523 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/LanguageType.java @@ -0,0 +1,10 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +/** Enum specifying a source or target language type. */ +public enum LanguageType { + Source, + Target, +} diff --git a/deepl-java/src/main/java/com/deepl/api/NotFoundException.java b/deepl-java/src/main/java/com/deepl/api/NotFoundException.java new file mode 100644 index 0000000..b7fd2b1 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/NotFoundException.java @@ -0,0 +1,11 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +/** Exception thrown when the specified resource could not be found. */ +public class NotFoundException extends DeepLException { + public NotFoundException(String message) { + super(message); + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/QuotaExceededException.java b/deepl-java/src/main/java/com/deepl/api/QuotaExceededException.java new file mode 100644 index 0000000..55fecee --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/QuotaExceededException.java @@ -0,0 +1,11 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +/** Exception thrown when the DeepL translation quota has been reached. */ +public class QuotaExceededException extends DeepLException { + public QuotaExceededException(String message) { + super(message); + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/SentenceSplittingMode.java b/deepl-java/src/main/java/com/deepl/api/SentenceSplittingMode.java new file mode 100644 index 0000000..84241d3 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/SentenceSplittingMode.java @@ -0,0 +1,24 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +/** Enum controlling how input translation text should be split into sentences. */ +public enum SentenceSplittingMode { + /** + * Input translation text will be split into sentences using both newlines and punctuation, this + * is the default behaviour. + */ + All, + + /** + * Input text will not be split into sentences. This is advisable for applications where each + * input translation text is only one sentence. + */ + Off, + + /** + * Input translation text will be split into sentences using only punctuation but not newlines. + */ + NoNewlines, +} diff --git a/deepl-java/src/main/java/com/deepl/api/TextResult.java b/deepl-java/src/main/java/com/deepl/api/TextResult.java new file mode 100644 index 0000000..d7c8b6b --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/TextResult.java @@ -0,0 +1,26 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +/** The result of a text translation. */ +public class TextResult { + private final String text; + private final String detectedSourceLanguage; + + /** Constructs a new instance. */ + public TextResult(String text, String detectedSourceLanguage) { + this.text = text; + this.detectedSourceLanguage = LanguageCode.standardize(detectedSourceLanguage); + } + + /** The translated text. */ + public String getText() { + return text; + } + + /** The language code of the source text detected by DeepL. */ + public String getDetectedSourceLanguage() { + return detectedSourceLanguage; + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/TextTranslationOptions.java b/deepl-java/src/main/java/com/deepl/api/TextTranslationOptions.java new file mode 100644 index 0000000..0f721df --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/TextTranslationOptions.java @@ -0,0 +1,161 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +/** + * Options to control text translation behaviour. These options may be provided to {@link + * Translator#translateText} overloads. + * + *

All properties have corresponding setters in fluent-style, so the following is possible: + * + * TextTranslationOptions options = new TextTranslationOptions() + * .setFormality(Formality.Less).setGlossaryId("f63c02c5-f056-.."); + * + */ +public class TextTranslationOptions { + private Formality formality; + private String glossaryId; + private SentenceSplittingMode sentenceSplittingMode; + private boolean preserveFormatting = false; + private String tagHandling; + private boolean outlineDetection = true; + private Iterable ignoreTags; + private Iterable nonSplittingTags; + private Iterable splittingTags; + + /** + * Sets whether translations should lean toward formal or informal language. This option is only + * applicable for target languages that support the formality option. By default, this value is + * null andnull translations use the default formality. + * + * @see Language#getSupportsFormality() + * @see Formality + */ + public TextTranslationOptions setFormality(Formality formality) { + this.formality = formality; + return this; + } + + /** + * Sets the ID of a glossary to use with the translation. By default, this value is + * null and no glossary is used. + */ + public TextTranslationOptions setGlossaryId(String glossaryId) { + this.glossaryId = glossaryId; + return this; + } + + /** + * Specifies how input translation text should be split into sentences. By default, this value is + * null and the default sentence splitting mode is used. + * + * @see SentenceSplittingMode + */ + public TextTranslationOptions setSentenceSplittingMode( + SentenceSplittingMode sentenceSplittingMode) { + this.sentenceSplittingMode = sentenceSplittingMode; + return this; + } + + /** + * Sets whether formatting should be preserved in translations. Set to true to + * prevent the translation engine from correcting some formatting aspects, and instead leave the + * formatting unchanged, default is false. + */ + public TextTranslationOptions setPreserveFormatting(boolean preserveFormatting) { + this.preserveFormatting = preserveFormatting; + return this; + } + + /** + * Set the type of tags to parse before translation, only "xml" and "html" + * are currently available. By default, this value is null and no + * tag-handling is used. + */ + public TextTranslationOptions setTagHandling(String tagHandling) { + this.tagHandling = tagHandling; + return this; + } + + /** + * Sets whether outline detection is used; set to false to disable automatic tag + * detection, default is true. + */ + public TextTranslationOptions setOutlineDetection(boolean outlineDetection) { + this.outlineDetection = outlineDetection; + return this; + } + + /** + * Sets the list of XML tags containing content that should not be translated. By default, this + * value is null and no tags are specified. + */ + public TextTranslationOptions setIgnoreTags(Iterable ignoreTags) { + this.ignoreTags = ignoreTags; + return this; + } + + /** + * Sets the list of XML tags that should not be used to split text into sentences. By default, + * this value is null and no tags are specified. + */ + public TextTranslationOptions setNonSplittingTags(Iterable nonSplittingTags) { + this.nonSplittingTags = nonSplittingTags; + return this; + } + + /** + * Set the list of XML tags that should be used to split text into sentences. By default, this + * value is null and no tags are specified. + */ + public TextTranslationOptions setSplittingTags(Iterable splittingTags) { + this.splittingTags = splittingTags; + return this; + } + + /** Gets the current formality setting. */ + public Formality getFormality() { + return formality; + } + + /** Gets the current glossary ID. */ + public String getGlossaryId() { + return glossaryId; + } + + /** Gets the current sentence splitting mode. */ + public SentenceSplittingMode getSentenceSplittingMode() { + return sentenceSplittingMode; + } + + /** Gets the current preserve formatting setting. */ + public boolean isPreserveFormatting() { + return preserveFormatting; + } + + /** Gets the current tag handling setting. */ + public String getTagHandling() { + return tagHandling; + } + + /** Gets the current outline detection setting. */ + public boolean isOutlineDetection() { + return outlineDetection; + } + + /** Gets the current ignore tags list. */ + public Iterable getIgnoreTags() { + return ignoreTags; + } + + /** Gets the current non-splitting tags list. */ + public Iterable getNonSplittingTags() { + return nonSplittingTags; + } + + /** Gets the current splitting tags list. */ + public Iterable getSplittingTags() { + return splittingTags; + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/TooManyRequestsException.java b/deepl-java/src/main/java/com/deepl/api/TooManyRequestsException.java new file mode 100644 index 0000000..7624192 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/TooManyRequestsException.java @@ -0,0 +1,11 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +/** Exception thrown when too many requests are made to the DeepL API too quickly. */ +public class TooManyRequestsException extends DeepLException { + public TooManyRequestsException(String message) { + super(message); + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/Translator.java b/deepl-java/src/main/java/com/deepl/api/Translator.java new file mode 100644 index 0000000..cd44766 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/Translator.java @@ -0,0 +1,802 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +import static java.lang.Math.max; +import static java.lang.Math.min; + +import com.deepl.api.http.HttpResponse; +import com.deepl.api.http.HttpResponseStream; +import com.deepl.api.parsing.Parser; +import com.deepl.api.utils.*; +import java.io.*; +import java.net.HttpURLConnection; +import java.util.*; +import org.jetbrains.annotations.Nullable; + +/** + * Client for the DeepL API. To use the DeepL API, initialize an instance of this class using your + * DeepL Authentication Key as found in your DeepL + * account. + */ +public class Translator { + /** Base URL for DeepL API Free accounts. */ + private static final String DEEPL_SERVER_URL_FREE = "https://api-free.deepl.com"; + /** Base URL for DeepL API Pro accounts */ + private static final String DEEPL_SERVER_URL_PRO = "https://api.deepl.com"; + + private final Parser jsonParser = new Parser(); + private final HttpClientWrapper httpClientWrapper; + + /** + * Initializes a new Translator object using your Authentication Key. + * + *

Note: This function does not establish a connection to the DeepL API. To check connectivity, + * use {@link Translator#getUsage()}. + * + * @param authKey DeepL Authentication Key as found in your DeepL account. + * @param options Additional options controlling Translator behaviour. + * @throws IllegalArgumentException If authKey is invalid. + */ + public Translator(String authKey, TranslatorOptions options) throws IllegalArgumentException { + if (authKey == null || authKey.length() == 0) { + throw new IllegalArgumentException("authKey must be a non-empty string"); + } + String serverUrl = + (options.getServerUrl() != null) + ? options.getServerUrl() + : (isFreeAccountAuthKey(authKey) ? DEEPL_SERVER_URL_FREE : DEEPL_SERVER_URL_PRO); + + Map headers = new HashMap<>(); + if (options.getHeaders() != null) { + headers.putAll(options.getHeaders()); + } + headers.putIfAbsent("Authorization", "DeepL-Auth-Key " + authKey); + headers.putIfAbsent("User-Agent", "deepl-java/0.1.0"); + + this.httpClientWrapper = + new HttpClientWrapper( + serverUrl, headers, options.getTimeout(), options.getProxy(), options.getMaxRetries()); + } + + /** + * Initializes a new Translator object using your Authentication Key. + * + *

Note: This function does not establish a connection to the DeepL API. To check connectivity, + * use {@link Translator#getUsage()}. + * + * @param authKey DeepL Authentication Key as found in your DeepL account. + * @throws IllegalArgumentException If authKey is invalid. + */ + public Translator(String authKey) throws IllegalArgumentException { + this(authKey, new TranslatorOptions()); + } + + /** + * Determines if the given DeepL Authentication Key belongs to an API Free account. + * + * @param authKey DeepL Authentication Key as found in your DeepL account. + * @return true if the Authentication Key belongs to an API Free account, otherwise + * false. + */ + public static boolean isFreeAccountAuthKey(String authKey) { + return authKey != null && authKey.endsWith(":fx"); + } + + /** + * Translate specified text from source language into target language. + * + * @param text Text to translate; must not be empty. + * @param sourceLang Language code of the input language, or null to use + * auto-detection. + * @param targetLang Language code of the desired output language. + * @param options Options influencing translation. + * @return Text translated into specified target language, and detected source language. + * @throws InterruptedException If the thread is interrupted during execution of this function. + * @throws DeepLException If any error occurs while communicating with the DeepL API. + */ + public TextResult translateText( + String text, + @Nullable String sourceLang, + String targetLang, + @Nullable TextTranslationOptions options) + throws InterruptedException, DeepLException { + ArrayList texts = new ArrayList<>(); + texts.add(text); + return translateText(texts, sourceLang, targetLang, options).get(0); + } + + /** + * Functions the same as {@link Translator#translateText(String, String, String, + * TextTranslationOptions)} but with default options. + * + * @see Translator#translateText(String, String, String, TextTranslationOptions) + */ + public TextResult translateText(String text, @Nullable String sourceLang, String targetLang) + throws DeepLException, InterruptedException { + return translateText(text, sourceLang, targetLang, null); + } + + /** + * Functions the same as {@link Translator#translateText(String, String, String, + * TextTranslationOptions)} but accepts {@link Language} objects for source and target languages, + * and uses default options. + * + * @see Translator#translateText(String, String, String, TextTranslationOptions) + */ + public TextResult translateText(String text, @Nullable Language sourceLang, Language targetLang) + throws DeepLException, InterruptedException { + return translateText( + text, (sourceLang != null) ? sourceLang.getCode() : null, targetLang.getCode(), null); + } + + /** + * Functions the same as {@link Translator#translateText(String, String, String, + * TextTranslationOptions)} but accepts {@link Language} objects for source and target languages. + * + * @see Translator#translateText(String, String, String, TextTranslationOptions) + */ + public TextResult translateText( + String text, + @Nullable Language sourceLang, + Language targetLang, + @Nullable TextTranslationOptions options) + throws DeepLException, InterruptedException { + return translateText( + text, (sourceLang != null) ? sourceLang.getCode() : null, targetLang.getCode(), options); + } + + /** + * Translate specified texts from source language into target language. + * + * @param texts List of texts to translate; each text must not be empty. + * @param sourceLang Language code of the input language, or null to use + * auto-detection. + * @param targetLang Language code of the desired output language. + * @param options Options influencing translation. + * @return List of texts translated into specified target language, and detected source language. + * @throws InterruptedException If the thread is interrupted during execution of this function. + * @throws DeepLException If any error occurs while communicating with the DeepL API. + */ + public List translateText( + List texts, + @Nullable String sourceLang, + String targetLang, + @Nullable TextTranslationOptions options) + throws DeepLException, InterruptedException { + Iterable> params = + createHttpParams(texts, sourceLang, targetLang, options); + HttpResponse response = httpClientWrapper.sendRequestWithBackoff("/v2/translate", params); + checkResponse(response, false); + return jsonParser.parseTextResult(response.getBody()); + } + + /** + * Functions the same as {@link Translator#translateText(List, String, String, + * TextTranslationOptions)} but accepts {@link Language} objects for source and target languages, + * and uses default options. + * + * @see Translator#translateText(List, String, String, TextTranslationOptions) + */ + public List translateText( + List texts, @Nullable Language sourceLang, Language targetLang) + throws DeepLException, InterruptedException { + return translateText( + texts, (sourceLang != null) ? sourceLang.getCode() : null, targetLang.getCode(), null); + } + + /** + * Functions the same as {@link Translator#translateText(List, String, String, + * TextTranslationOptions)} but accepts {@link Language} objects for source and target languages. + * + * @see Translator#translateText(List, String, String, TextTranslationOptions) + */ + public List translateText( + List texts, + @Nullable Language sourceLang, + Language targetLang, + @Nullable TextTranslationOptions options) + throws DeepLException, InterruptedException { + return translateText( + texts, (sourceLang != null) ? sourceLang.getCode() : null, targetLang.getCode(), options); + } + + /** + * Functions the same as {@link Translator#translateText(List, String, String, + * TextTranslationOptions)} but uses default options. + * + * @see Translator#translateText(List, String, String, TextTranslationOptions) + */ + public List translateText( + List texts, @Nullable String sourceLang, String targetLang) + throws DeepLException, InterruptedException { + return translateText(texts, sourceLang, targetLang, null); + } + + /** + * Retrieves the usage in the current billing period for this DeepL account. This function can + * also be used to check connectivity with the DeepL API and that the account has access. + * + * @return {@link Usage} object containing account usage information. + * @throws InterruptedException If the thread is interrupted during execution of this function. + * @throws DeepLException If any error occurs while communicating with the DeepL API. + */ + public Usage getUsage() throws DeepLException, InterruptedException { + HttpResponse response = httpClientWrapper.sendRequestWithBackoff("/v2/usage"); + checkResponse(response, false); + return jsonParser.parseUsage(response.getBody()); + } + + /** + * Retrieves the list of supported translation source languages. + * + * @return List of {@link Language} objects representing the available translation source + * languages. + * @throws InterruptedException If the thread is interrupted during execution of this function. + * @throws DeepLException If any error occurs while communicating with the DeepL API. + */ + public List getSourceLanguages() throws DeepLException, InterruptedException { + return getLanguages(LanguageType.Source); + } + + /** + * Retrieves the list of supported translation target languages. + * + * @return List of {@link Language} objects representing the available translation target + * languages. + * @throws InterruptedException If the thread is interrupted during execution of this function. + * @throws DeepLException If any error occurs while communicating with the DeepL API. + */ + public List getTargetLanguages() throws DeepLException, InterruptedException { + return getLanguages(LanguageType.Target); + } + + /** + * Retrieves the list of supported translation source or target languages. + * + * @param languageType The type of languages to retrieve, source or target. + * @return List of {@link Language} objects representing the available translation source or + * target languages. + * @throws InterruptedException If the thread is interrupted during execution of this function. + * @throws DeepLException If any error occurs while communicating with the DeepL API. + */ + public List getLanguages(LanguageType languageType) + throws DeepLException, InterruptedException { + ArrayList> params = new ArrayList<>(); + if (languageType == LanguageType.Target) { + params.add(new KeyValuePair<>("type", "target")); + } + HttpResponse response = httpClientWrapper.sendRequestWithBackoff("/v2/languages", params); + checkResponse(response, false); + return jsonParser.parseLanguages(response.getBody()); + } + + /** + * Translate specified document content from source language to target language and store the + * translated document content to specified stream. + * + * @param inputFile File to upload to be translated. + * @param outputFile File to download translated document to. + * @param sourceLang Language code of the input language, or null to use + * auto-detection. + * @param targetLang Language code of the desired output language. + * @param options Options influencing translation. + * @return Status when document translation completed, this allows the number of billed characters + * to be queried. + * @throws IOException If the output path is occupied or the input file does not exist. + * @throws DocumentTranslationException If any error occurs while communicating with the DeepL + * API, or if the thread is interrupted during execution of this function. The exception + * includes the document handle that may be used to retrieve the document. + */ + public DocumentStatus translateDocument( + File inputFile, + File outputFile, + @Nullable String sourceLang, + String targetLang, + @Nullable DocumentTranslationOptions options) + throws DocumentTranslationException, IOException { + try { + if (outputFile.exists()) { + throw new IOException("File already exists at output path"); + } + try (InputStream inputStream = new FileInputStream(inputFile); + OutputStream outputStream = new FileOutputStream(outputFile)) { + return translateDocument( + inputStream, inputFile.getName(), outputStream, sourceLang, targetLang, options); + } + } catch (Exception exception) { + outputFile.delete(); + throw exception; + } + } + + /** + * Functions the same as {@link Translator#translateDocument(File, File, String, String, + * DocumentTranslationOptions)} but uses default options. + * + * @see Translator#translateDocument(File, File, String, String, DocumentTranslationOptions) + */ + public DocumentStatus translateDocument( + File inputFile, File outputFile, @Nullable String sourceLang, String targetLang) + throws DocumentTranslationException, IOException { + return translateDocument(inputFile, outputFile, sourceLang, targetLang, null); + } + + /** + * Translate specified document content from source language to target language and store the + * translated document content to specified stream. On return, input stream will be at end of + * stream and neither stream will be closed. + * + * @param inputStream Stream containing file to upload to be translated. + * @param fileName Name of the input file. The file extension is used to determine file type. + * @param outputStream Stream to download translated document to. + * @param sourceLang Language code of the input language, or null to use + * auto-detection. + * @param targetLang Language code of the desired output language. + * @param options Options influencing translation. + * @return Status when document translation completed, this allows the number of billed characters + * to be queried. + * @throws DocumentTranslationException If any error occurs while communicating with the DeepL + * API, or if the thread is interrupted during execution of this function. The exception + * includes the document handle that may be used to retrieve the document. + */ + public DocumentStatus translateDocument( + InputStream inputStream, + String fileName, + OutputStream outputStream, + @Nullable String sourceLang, + String targetLang, + @Nullable DocumentTranslationOptions options) + throws DocumentTranslationException { + DocumentHandle handle = null; + try { + handle = translateDocumentUpload(inputStream, fileName, sourceLang, targetLang, options); + DocumentStatus status = translateDocumentWaitUntilDone(handle); + translateDocumentDownload(handle, outputStream); + return status; + } catch (Exception exception) { + throw new DocumentTranslationException( + "Error occurred during document translation: " + exception.getMessage(), + exception, + handle); + } + } + + /** + * Functions the same as {@link Translator#translateDocument(InputStream, String, OutputStream, + * String, String, DocumentTranslationOptions)} but uses default options. + * + * @see Translator#translateDocument(InputStream, String, OutputStream, String, String, + * DocumentTranslationOptions) + */ + public DocumentStatus translateDocument( + InputStream inputFile, + String fileName, + OutputStream outputFile, + @Nullable String sourceLang, + String targetLang) + throws DocumentTranslationException { + return translateDocument(inputFile, fileName, outputFile, sourceLang, targetLang, null); + } + + /** + * Upload document at specified input path for translation from source language to target + * language. See the DeepL API + * documentation for the currently supported document types. + * + * @param inputFile File containing document to be translated. + * @param sourceLang Language code of the input language, or null to use + * auto-detection. + * @param targetLang Language code of the desired output language. + * @param options Options influencing translation. + * @return Handle associated with the in-progress document translation. + * @throws IOException If the input file does not exist. + * @throws InterruptedException If the thread is interrupted during execution of this function. + * @throws DeepLException If any error occurs while communicating with the DeepL API. + */ + public DocumentHandle translateDocumentUpload( + File inputFile, + @Nullable String sourceLang, + String targetLang, + @Nullable DocumentTranslationOptions options) + throws DeepLException, IOException, InterruptedException { + Iterable> params = + createHttpParams(sourceLang, targetLang, options); + try (FileInputStream inputStream = new FileInputStream(inputFile)) { + HttpResponse response = + httpClientWrapper.uploadWithBackoff( + "/v2/document/", params, inputFile.getName(), inputStream); + checkResponse(response, false); + return jsonParser.parseDocumentHandle(response.getBody()); + } + } + + /** + * Functions the same as {@link Translator#translateDocumentUpload(File, String, String, + * DocumentTranslationOptions)} but uses default options. + * + * @see Translator#translateDocumentUpload(File, String, String, DocumentTranslationOptions) + */ + public DocumentHandle translateDocumentUpload( + File inputFile, @Nullable String sourceLang, String targetLang) + throws DeepLException, IOException, InterruptedException { + return translateDocumentUpload(inputFile, sourceLang, targetLang, null); + } + + /** + * Upload document at specified input path for translation from source language to target + * language. See the DeepL API + * documentation for the currently supported document types. + * + * @param inputStream Stream containing document to be translated. On return, input stream will be + * at end of stream and will not be closed. + * @param fileName Name of the input file. The file extension is used to determine file type. + * @param sourceLang Language code of the input language, or null to use + * auto-detection. + * @param targetLang Language code of the desired output language. + * @param options Options influencing translation. + * @return Handle associated with the in-progress document translation. + * @throws InterruptedException If the thread is interrupted during execution of this function. + * @throws DeepLException If any error occurs while communicating with the DeepL API. + */ + public DocumentHandle translateDocumentUpload( + InputStream inputStream, + String fileName, + @Nullable String sourceLang, + String targetLang, + @Nullable DocumentTranslationOptions options) + throws DeepLException, InterruptedException { + Iterable> params = + createHttpParams(sourceLang, targetLang, options); + HttpResponse response = + httpClientWrapper.uploadWithBackoff("/v2/document/", params, fileName, inputStream); + checkResponse(response, false); + return jsonParser.parseDocumentHandle(response.getBody()); + } + + /** + * Functions the same as {@link Translator#translateDocumentUpload(InputStream, String, String, + * String, DocumentTranslationOptions)} but uses default options. + * + * @see Translator#translateDocumentUpload(InputStream, String, String, String, + * DocumentTranslationOptions) + */ + public DocumentHandle translateDocumentUpload( + InputStream inputStream, String fileName, @Nullable String sourceLang, String targetLang) + throws DeepLException, InterruptedException { + return translateDocumentUpload(inputStream, fileName, sourceLang, targetLang, null); + } + + /** + * Retrieve the status of in-progress document translation associated with specified handle. + * + * @param handle Handle associated with document translation to check. + * @return Status of the document translation. + * @throws InterruptedException If the thread is interrupted during execution of this function. + * @throws DeepLException If any error occurs while communicating with the DeepL API. + */ + public DocumentStatus translateDocumentStatus(DocumentHandle handle) + throws DeepLException, InterruptedException { + ArrayList> params = new ArrayList<>(); + params.add(new KeyValuePair<>("document_key", handle.getDocumentKey())); + String relativeUrl = String.format("/v2/document/%s", handle.getDocumentId()); + HttpResponse response = httpClientWrapper.sendRequestWithBackoff(relativeUrl, params); + checkResponse(response, false); + return jsonParser.parseDocumentStatus(response.getBody()); + } + + /** + * Checks document translation status and waits until document translation is complete or fails + * due to an error. + * + * @param handle Handle associated with document translation to wait for. + * @return Status when document translation completed, this allows the number of billed characters + * to be queried. + * @throws InterruptedException If the thread is interrupted while waiting for the document + * translation to complete. + * @throws DeepLException If any error occurs while communicating with the DeepL API. + */ + public DocumentStatus translateDocumentWaitUntilDone(DocumentHandle handle) + throws InterruptedException, DeepLException { + DocumentStatus status = translateDocumentStatus(handle); + while (status.ok() && !status.done()) { + Thread.sleep(calculateDocumentWaitTimeMillis(status.getSecondsRemaining())); + status = translateDocumentStatus(handle); + } + + if (!status.ok()) { + String message = + (status.getErrorMessage() != null) ? status.getErrorMessage() : "Unknown error"; + throw new DeepLException(message); + } + return status; + } + + /** + * Downloads the resulting translated document associated with specified handle to the specified + * output file. The document translation must be complete i.e. {@link DocumentStatus#done()} for + * the document status must be true. + * + * @param handle Handle associated with document translation to download. + * @param outputFile File to download translated document to. + * @throws IOException If the output path is occupied. + * @throws InterruptedException If the thread is interrupted during execution of this function. + * @throws DeepLException If any error occurs while communicating with the DeepL API. + */ + public void translateDocumentDownload(DocumentHandle handle, File outputFile) + throws DeepLException, IOException, InterruptedException { + try { + if (outputFile.exists()) { + throw new IOException("File already exists at output path"); + } + try (FileOutputStream outputStream = new FileOutputStream(outputFile)) { + translateDocumentDownload(handle, outputStream); + } + } catch (Exception exception) { + outputFile.delete(); + throw exception; + } + } + + /** + * Downloads the resulting translated document associated with specified handle to the specified + * output stream. The document translation must be complete i.e. {@link DocumentStatus#done()} for + * the document status must be true. The output stream is not closed. + * + * @param handle Handle associated with document translation to download. + * @param outputStream Stream to download translated document to. + * @throws IOException If an I/O error occurs. + * @throws InterruptedException If the thread is interrupted during execution of this function. + * @throws DeepLException If any error occurs while communicating with the DeepL API. + */ + public void translateDocumentDownload(DocumentHandle handle, OutputStream outputStream) + throws DeepLException, IOException, InterruptedException { + ArrayList> params = new ArrayList<>(); + params.add(new KeyValuePair<>("document_key", handle.getDocumentKey())); + String relativeUrl = String.format("/v2/document/%s/result", handle.getDocumentId()); + try (HttpResponseStream response = httpClientWrapper.downloadWithBackoff(relativeUrl, params)) { + checkResponse(response); + assert response.getBody() != null; + StreamUtil.transferTo(response.getBody(), outputStream); + } + } + + /** + * Checks the specified texts, languages and options are valid, and returns an iterable of + * containing the parameters to include in HTTP request. + * + * @param texts Iterable of texts to translate. + * @param sourceLang Language code of the input language, or null to use + * auto-detection. + * @param targetLang Language code of the desired output language. + * @param options Options influencing translation. + * @return Iterable of parameters for HTTP request. + */ + private static ArrayList> createHttpParams( + List texts, + @Nullable String sourceLang, + String targetLang, + @Nullable TextTranslationOptions options) { + ArrayList> params = + createHttpParamsCommon( + sourceLang, + targetLang, + options != null ? options.getFormality() : null, + options != null ? options.getGlossaryId() : null); + texts.forEach( + (text) -> { + if (text.isEmpty()) throw new IllegalArgumentException("text must not be empty"); + params.add(new KeyValuePair<>("text", text)); + }); + + if (options != null) { + // Note: formality and glossaryId are added above + if (options.getSentenceSplittingMode() != null + && options.getSentenceSplittingMode() != SentenceSplittingMode.All) { + switch (options.getSentenceSplittingMode()) { + case Off: + params.add(new KeyValuePair<>("split_sentences", "0")); + break; + case NoNewlines: + params.add(new KeyValuePair<>("split_sentences", "nonewlines")); + break; + default: + break; + } + } + if (options.isPreserveFormatting()) { + params.add(new KeyValuePair<>("preserve_formatting", "1")); + } + if (options.getTagHandling() != null) { + params.add(new KeyValuePair<>("tag_handling", options.getTagHandling())); + } + if (!options.isOutlineDetection()) { + params.add(new KeyValuePair<>("outline_detection", "0")); + } + if (options.getSplittingTags() != null) { + params.add(new KeyValuePair<>("splitting_tags", joinTags(options.getSplittingTags()))); + } + if (options.getNonSplittingTags() != null) { + params.add( + new KeyValuePair<>("non_splitting_tags", joinTags(options.getNonSplittingTags()))); + } + if (options.getIgnoreTags() != null) { + params.add(new KeyValuePair<>("ignore_tags", joinTags(options.getIgnoreTags()))); + } + } + return params; + } + + /** + * Checks the specified languages and document translation options are valid, and returns an + * iterable of containing the parameters to include in HTTP request. + * + * @param sourceLang Language code of the input language, or null to use + * auto-detection. + * @param targetLang Language code of the desired output language. + * @param options Options influencing translation. + * @return Iterable of parameters for HTTP request. + */ + private static ArrayList> createHttpParams( + String sourceLang, String targetLang, DocumentTranslationOptions options) { + return createHttpParamsCommon( + sourceLang, + targetLang, + options != null ? options.getFormality() : null, + options != null ? options.getGlossaryId() : null); + } + + /** + * Checks the specified parameters common to both text and document translation are valid, and + * returns an iterable of containing the parameters to include in HTTP request. + * + * @param sourceLang Language code of the input language, or null to use + * auto-detection. + * @param targetLang Language code of the desired output language. + * @param formality Formality option for translation. + * @param glossaryId ID of glossary to use for translation. + * @return Iterable of parameters for HTTP request. + */ + private static ArrayList> createHttpParamsCommon( + @Nullable String sourceLang, + String targetLang, + @Nullable Formality formality, + @Nullable String glossaryId) { + targetLang = LanguageCode.standardize(targetLang); + sourceLang = sourceLang == null ? null : LanguageCode.standardize(sourceLang); + checkValidLanguages(sourceLang, targetLang); + + ArrayList> params = new ArrayList<>(); + if (sourceLang != null) { + params.add(new KeyValuePair<>("source_lang", sourceLang)); + } + params.add(new KeyValuePair<>("target_lang", targetLang)); + + if (formality != null && formality != Formality.Default) { + switch (formality) { + case More: + params.add(new KeyValuePair<>("formality", "more")); + break; + case Less: + params.add(new KeyValuePair<>("formality", "less")); + break; + default: + break; + } + } + + if (glossaryId != null) { + params.add(new KeyValuePair<>("glossary_id", glossaryId)); + } + + return params; + } + + /** Combine XML tags with comma-delimiter to be included in HTTP request parameters. */ + private static String joinTags(Iterable tags) { + return String.join(",", tags); + } + + /** + * Checks the specified source and target language are valid. + * + * @param sourceLang Language code of the input language, or null to use + * auto-detection. + * @param targetLang Language code of the desired output language. + * @throws IllegalArgumentException If either language code is invalid. + */ + private static void checkValidLanguages(@Nullable String sourceLang, String targetLang) + throws IllegalArgumentException { + if (sourceLang != null && sourceLang.isEmpty()) { + throw new IllegalArgumentException("sourceLang must be null or non-empty"); + } + if (targetLang.isEmpty()) { + throw new IllegalArgumentException("targetLang must not be empty"); + } + switch (targetLang) { + case "en": + throw new IllegalArgumentException( + "targetLang=\"en\" is not allowed, please use \"en-GB\" or \"en-US\" instead"); + case "pt": + throw new IllegalArgumentException( + "targetLang=\"pt\" is not allowed, please use \"pt-PT\" or \"pt-BR\" instead"); + default: + break; + } + } + + /** + * Functions the same as {@link Translator#checkResponse(HttpResponse, boolean)} but accepts + * response stream for document downloads. If the HTTP status code represents failure, the + * response stream is converted to a String response to throw the appropriate exception. + * + * @see Translator#checkResponse(HttpResponse, boolean) + */ + private void checkResponse(HttpResponseStream response) throws DeepLException { + if (response.getCode() >= HttpURLConnection.HTTP_OK + && response.getCode() < HttpURLConnection.HTTP_BAD_REQUEST) { + return; + } + if (response.getBody() == null) { + throw new DeepLException("response stream is empty"); + } + checkResponse(response.toStringResponse(), true); + } + + /** + * Checks the response HTTP status is OK, otherwise throws corresponding exception. + * + * @param response Response received from DeepL API. + * @throws DeepLException Throws {@link DeepLException} or a derived exception depending on the + * type of error. + */ + private void checkResponse(HttpResponse response, boolean inDocumentDownload) + throws DeepLException { + if (response.getCode() >= 200 && response.getCode() < 300) { + return; + } + + String messageSuffix = jsonParser.parseErrorMessage(response.getBody()); + if (!messageSuffix.isEmpty()) { + messageSuffix = ", " + messageSuffix; + } + switch (response.getCode()) { + case HttpURLConnection.HTTP_BAD_REQUEST: + throw new DeepLException("Bad request" + messageSuffix); + case HttpURLConnection.HTTP_FORBIDDEN: + throw new AuthorizationException("Authorization failure, check auth_key" + messageSuffix); + case HttpURLConnection.HTTP_NOT_FOUND: + throw new NotFoundException("Not found, check serverUrl" + messageSuffix); + case 429: + throw new TooManyRequestsException( + "Too many requests, DeepL servers are currently experiencing high load" + + messageSuffix); + case 456: + throw new QuotaExceededException( + "Quota for this billing period has been exceeded" + messageSuffix); + case HttpURLConnection.HTTP_UNAVAILABLE: + { + if (inDocumentDownload) { + throw new DocumentNotReadyException("Document not ready" + messageSuffix); + } else { + throw new DeepLException("Service unavailable" + messageSuffix); + } + } + default: + throw new DeepLException("Unknown error" + messageSuffix); + } + } + + private int calculateDocumentWaitTimeMillis(Long secondsRemaining) { + if (secondsRemaining != null) { + double secs = ((double) secondsRemaining) / 2.0 + 1.0; + secs = max(1.0, min(secs, 60.0)); + return (int) (secs * 1000); + } + return 1000; + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/TranslatorOptions.java b/deepl-java/src/main/java/com/deepl/api/TranslatorOptions.java new file mode 100644 index 0000000..e080ac9 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/TranslatorOptions.java @@ -0,0 +1,96 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +import java.net.Proxy; +import java.time.Duration; +import java.util.Map; +import org.jetbrains.annotations.Nullable; + +/** + * Options to control translator behaviour. These options may be provided to the {@link Translator} + * constructor. + * + *

All properties have corresponding setters in fluent-style, so the following is possible: + * + * TranslatorOptions options = new TranslatorOptions() + * .setTimeout(Duration.ofSeconds(1)).setMaxRetries(2); + * + */ +public class TranslatorOptions { + private int maxRetries = 5; + private Duration timeout = Duration.ofSeconds(10); + @Nullable private Proxy proxy = null; + @Nullable private Map headers = null; + @Nullable private String serverUrl = null; + + /** + * Set the maximum number of failed attempts that {@link Translator} will retry, per request. By + * default, 5 retries are made. Note: only errors due to transient conditions are retried. + */ + public TranslatorOptions setMaxRetries(int maxRetries) { + this.maxRetries = maxRetries; + return this; + } + + /** Set the connection timeout used for each HTTP request retry, the default is 10 seconds. */ + public TranslatorOptions setTimeout(Duration timeout) { + this.timeout = timeout; + return this; + } + + /** + * Set the proxy to use for HTTP requests. By default, this value is null and no + * proxy will be used. + */ + public TranslatorOptions setProxy(Proxy proxy) { + this.proxy = proxy; + return this; + } + + /** + * Set HTTP headers attached to every HTTP request. By default, this value is null + * and no extra headers are used. Note that in the {@link Translator} constructor the headers for + * Authorization and User-Agent are added, unless they are overridden in this option. + */ + public TranslatorOptions setHeaders(Map headers) { + this.headers = headers; + return this; + } + + /** + * Set the base URL for DeepL API that may be overridden for testing purposes. By default, this + * value is null and the correct DeepL API base URL is selected based on the API + * account type (free or paid). + */ + public TranslatorOptions setServerUrl(String serverUrl) { + this.serverUrl = serverUrl; + return this; + } + + /** Gets the current maximum number of retries. */ + public int getMaxRetries() { + return maxRetries; + } + + /** Gets the current maximum request timeout. */ + public Duration getTimeout() { + return timeout; + } + + /** Gets the current proxy. */ + public @Nullable Proxy getProxy() { + return proxy; + } + + /** Gets the current HTTP headers. */ + public @Nullable Map getHeaders() { + return headers; + } + + /** Gets the current custom server URL. */ + public @Nullable String getServerUrl() { + return serverUrl; + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/Usage.java b/deepl-java/src/main/java/com/deepl/api/Usage.java new file mode 100644 index 0000000..eddeafd --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/Usage.java @@ -0,0 +1,109 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +import java.util.function.BiConsumer; +import org.jetbrains.annotations.Nullable; + +/** + * Information about DeepL account usage for the current billing period, for example the number of + * characters translated. + * + *

Depending on the account type, some usage types will be omitted. See the API documentation for more information. + */ +public class Usage { + private final @Nullable Detail character; + private final @Nullable Detail document; + private final @Nullable Detail teamDocument; + + /** The character usage if included for the account type, or null. */ + public @Nullable Detail getCharacter() { + return character; + } + + /** The document usage if included for the account type, or null. */ + public @Nullable Detail getDocument() { + return document; + } + + /** The team document usage if included for the account type, or null. */ + public @Nullable Detail getTeamDocument() { + return teamDocument; + } + + /** Stores the amount used and maximum amount for one usage type. */ + public static class Detail { + private final int count; + private final int limit; + + public Detail(int count, int limit) { + this.count = count; + this.limit = limit; + } + + /** @return The currently used number of items for this usage type. */ + public int getCount() { + return count; + } + + /** @return The maximum permitted number of items for this usage type. */ + public int getLimit() { + return limit; + } + + /** + * @return true if the amount used meets or exceeds the limit, otherwise + * false. + */ + public boolean limitReached() { + return getCount() >= getLimit(); + } + + @Override + public String toString() { + return getCount() + " of " + getLimit(); + } + } + + public Usage( + @Nullable Detail character, @Nullable Detail document, @Nullable Detail teamDocument) { + this.character = character; + this.document = document; + this.teamDocument = teamDocument; + } + + /** + * @return true if any of the usage types included for the account type have been + * reached, otherwise false. + */ + public boolean anyLimitReached() { + return (getCharacter() != null && getCharacter().limitReached()) + || (getDocument() != null && getDocument().limitReached()) + || (getTeamDocument() != null && getTeamDocument().limitReached()); + } + + /** + * Returns a string representing the usage. This function is for diagnostic purposes only; the + * content of the returned string is exempt from backwards compatibility. + * + * @return A string containing the usage for this billing period. + */ + @Override + public String toString() { + StringBuilder sb = new StringBuilder("Usage this billing period:"); + + BiConsumer addLabelledDetail = + (label, detail) -> { + if (detail != null) { + sb.append("\n").append(label).append(": ").append(detail); + } + }; + + addLabelledDetail.accept("Characters", getCharacter()); + addLabelledDetail.accept("Documents", getDocument()); + addLabelledDetail.accept("Team documents", getTeamDocument()); + return sb.toString(); + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/http/HttpContent.java b/deepl-java/src/main/java/com/deepl/api/http/HttpContent.java new file mode 100644 index 0000000..d38ac38 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/http/HttpContent.java @@ -0,0 +1,114 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api.http; + +import com.deepl.api.*; +import com.deepl.api.utils.*; +import java.io.*; +import java.net.*; +import java.nio.charset.*; +import java.util.*; +import org.jetbrains.annotations.*; + +public class HttpContent { + private static final String LINE_BREAK = "\r\n"; + private final String contentType; + private final byte[] content; + + private HttpContent(String contentType, byte[] content) { + this.contentType = contentType; + this.content = content; + } + + public byte[] getContent() { + return content; + } + + public String getContentType() { + return contentType; + } + + public static HttpContent buildFormURLEncodedContent( + @Nullable Iterable> params) throws DeepLException { + StringBuilder sb = new StringBuilder(); + if (params != null) { + for (KeyValuePair pair : params) { + if (sb.length() != 0) sb.append("&"); + sb.append(urlEncode(pair.getKey())); + sb.append("="); + sb.append(urlEncode(pair.getValue())); + } + } + return new HttpContent( + "application/x-www-form-urlencoded", sb.toString().getBytes(StandardCharsets.UTF_8)); + } + + private static String urlEncode(String value) throws DeepLException { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException exception) { + throw new DeepLException("Error while URL-encoding request", exception); + } + } + + public static HttpContent buildMultipartFormDataContent( + Iterable> params) throws Exception { + String boundary = UUID.randomUUID().toString(); + return buildMultipartFormDataContent(params, boundary); + } + + private static HttpContent buildMultipartFormDataContent( + Iterable> params, String boundary) throws Exception { + try (ByteArrayOutputStream stream = new ByteArrayOutputStream(); + PrintWriter writer = + new PrintWriter(new OutputStreamWriter(stream, StandardCharsets.UTF_8))) { + + if (params != null) { + for (KeyValuePair entry : params) { + String key = entry.getKey(); + Object value = entry.getValue(); + if (entry.getValue() instanceof NamedStream) { + NamedStream namedStream = (NamedStream) entry.getValue(); + String probableContentType = + URLConnection.guessContentTypeFromName(namedStream.getFileName()); + writer.append("--").append(boundary).append(LINE_BREAK); + writer + .append("Content-Disposition: form-data; name=\"") + .append(key) + .append("\"; filename=\"") + .append(namedStream.getFileName()) + .append("\"") + .append(LINE_BREAK); + writer.append("Content-Type: ").append(probableContentType).append(LINE_BREAK); + writer.append("Content-Transfer-Encoding: binary").append(LINE_BREAK); + writer.append(LINE_BREAK); + writer.flush(); + + StreamUtil.transferTo(namedStream.getInputStream(), stream); + + writer.append(LINE_BREAK); + writer.flush(); + } else if (value instanceof String) { + writer.append("--").append(boundary).append(LINE_BREAK); + writer + .append("Content-Disposition: form-data; name=\"") + .append(key) + .append("\"") + .append(LINE_BREAK); + writer.append(LINE_BREAK); + writer.append((String) value).append(LINE_BREAK); + writer.flush(); + } else { + throw new Exception("Unknown argument type: " + value.getClass().getName()); + } + } + } + + writer.append("--").append(boundary).append("--").append(LINE_BREAK); + writer.flush(); + writer.close(); + return new HttpContent("multipart/form-data; boundary=" + boundary, stream.toByteArray()); + } + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/http/HttpResponse.java b/deepl-java/src/main/java/com/deepl/api/http/HttpResponse.java new file mode 100644 index 0000000..eadcf00 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/http/HttpResponse.java @@ -0,0 +1,24 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api.http; + +public class HttpResponse { + + private final int code; + + private final String body; + + public HttpResponse(int code, String body) { + this.code = code; + this.body = body; + } + + public int getCode() { + return code; + } + + public String getBody() { + return body; + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/http/HttpResponseStream.java b/deepl-java/src/main/java/com/deepl/api/http/HttpResponseStream.java new file mode 100644 index 0000000..5d51d19 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/http/HttpResponseStream.java @@ -0,0 +1,50 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api.http; + +import com.deepl.api.*; +import com.deepl.api.utils.*; +import java.io.*; +import org.jetbrains.annotations.*; + +public class HttpResponseStream implements AutoCloseable { + + private final int code; + + @Nullable private final InputStream body; + + public HttpResponseStream(int code, @Nullable InputStream body) { + this.code = code; + this.body = body; + } + + public void close() { + try { + if (this.body != null) { + this.body.close(); + } + } catch (Exception e) { + // ignore + } + } + + public HttpResponse toStringResponse() throws DeepLException { + try { + String content = this.body == null ? "" : StreamUtil.readStream(this.body); + return new HttpResponse(getCode(), content); + } catch (IOException exception) { + throw new DeepLException("Error reading stream", exception); + } finally { + close(); + } + } + + public int getCode() { + return code; + } + + public @Nullable InputStream getBody() { + return body; + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/parsing/ErrorResponse.java b/deepl-java/src/main/java/com/deepl/api/parsing/ErrorResponse.java new file mode 100644 index 0000000..31468e4 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/parsing/ErrorResponse.java @@ -0,0 +1,21 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api.parsing; + +import org.jetbrains.annotations.Nullable; + +class ErrorResponse { + @Nullable String message; + @Nullable String detail; + + public String getErrorMessage() { + StringBuilder sb = new StringBuilder(); + if (message != null) sb.append("message: ").append(message); + if (detail != null) { + if (sb.length() != 0) sb.append(", "); + sb.append("detail: ").append(detail); + } + return sb.toString(); + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/parsing/LanguageDeserializer.java b/deepl-java/src/main/java/com/deepl/api/parsing/LanguageDeserializer.java new file mode 100644 index 0000000..da16b1c --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/parsing/LanguageDeserializer.java @@ -0,0 +1,20 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api.parsing; + +import com.deepl.api.Language; +import com.google.gson.*; +import java.lang.reflect.Type; + +class LanguageDeserializer implements JsonDeserializer { + public Language deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + Boolean supportsFormality = Parser.getAsBooleanOrNull(jsonObject, "supports_formality"); + return new Language( + jsonObject.get("name").getAsString(), + jsonObject.get("language").getAsString(), + supportsFormality); + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/parsing/Parser.java b/deepl-java/src/main/java/com/deepl/api/parsing/Parser.java new file mode 100644 index 0000000..b761bb3 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/parsing/Parser.java @@ -0,0 +1,70 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api.parsing; + +import com.deepl.api.*; +import com.google.gson.*; +import com.google.gson.reflect.*; +import java.lang.reflect.*; +import java.util.*; +import org.jetbrains.annotations.*; + +public class Parser { + private final Gson gson; + + public Parser() { + GsonBuilder gsonBuilder = new GsonBuilder(); + gsonBuilder.registerTypeAdapter(TextResult.class, new TextResultDeserializer()); + gsonBuilder.registerTypeAdapter(Language.class, new LanguageDeserializer()); + gsonBuilder.registerTypeAdapter(Usage.class, new UsageDeserializer()); + gson = gsonBuilder.create(); + } + + public List parseTextResult(String json) { + TextResponse result = gson.fromJson(json, TextResponse.class); + return result.translations; + } + + public Usage parseUsage(String json) { + return gson.fromJson(json, Usage.class); + } + + public List parseLanguages(String json) { + Type languageListType = new TypeToken>() {}.getType(); + return gson.fromJson(json, languageListType); + } + + public DocumentStatus parseDocumentStatus(String json) { + return gson.fromJson(json, DocumentStatus.class); + } + + public DocumentHandle parseDocumentHandle(String json) { + return gson.fromJson(json, DocumentHandle.class); + } + + public String parseErrorMessage(String json) { + ErrorResponse response = gson.fromJson(json, ErrorResponse.class); + + if (response != null) { + return response.getErrorMessage(); + } else { + return ""; + } + } + + static @Nullable Integer getAsIntOrNull(JsonObject jsonObject, String parameterName) { + if (!jsonObject.has(parameterName)) return null; + return jsonObject.get(parameterName).getAsInt(); + } + + static @Nullable String getAsStringOrNull(JsonObject jsonObject, String parameterName) { + if (!jsonObject.has(parameterName)) return null; + return jsonObject.get(parameterName).getAsString(); + } + + static @Nullable Boolean getAsBooleanOrNull(JsonObject jsonObject, String parameterName) { + if (!jsonObject.has(parameterName)) return null; + return jsonObject.get(parameterName).getAsBoolean(); + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/parsing/TextResponse.java b/deepl-java/src/main/java/com/deepl/api/parsing/TextResponse.java new file mode 100644 index 0000000..c6515d1 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/parsing/TextResponse.java @@ -0,0 +1,11 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api.parsing; + +import com.deepl.api.TextResult; +import java.util.List; + +class TextResponse { + public List translations; +} diff --git a/deepl-java/src/main/java/com/deepl/api/parsing/TextResultDeserializer.java b/deepl-java/src/main/java/com/deepl/api/parsing/TextResultDeserializer.java new file mode 100644 index 0000000..43a849f --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/parsing/TextResultDeserializer.java @@ -0,0 +1,18 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api.parsing; + +import com.deepl.api.TextResult; +import com.google.gson.*; +import java.lang.reflect.Type; + +class TextResultDeserializer implements JsonDeserializer { + public TextResult deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + return new TextResult( + jsonObject.get("text").getAsString(), + jsonObject.get("detected_source_language").getAsString()); + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/parsing/UsageDeserializer.java b/deepl-java/src/main/java/com/deepl/api/parsing/UsageDeserializer.java new file mode 100644 index 0000000..ce2d417 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/parsing/UsageDeserializer.java @@ -0,0 +1,28 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api.parsing; + +import com.deepl.api.*; +import com.google.gson.*; +import java.lang.reflect.*; +import org.jetbrains.annotations.*; + +class UsageDeserializer implements JsonDeserializer { + public Usage deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + + return new Usage( + createDetail(jsonObject, "character_"), + createDetail(jsonObject, "document_"), + createDetail(jsonObject, "team_document_")); + } + + public static @Nullable Usage.Detail createDetail(JsonObject jsonObject, String prefix) { + Integer count = Parser.getAsIntOrNull(jsonObject, prefix + "count"); + Integer limit = Parser.getAsIntOrNull(jsonObject, prefix + "limit"); + if (count == null || limit == null) return null; + return new Usage.Detail(count, limit); + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/utils/BackoffTimer.java b/deepl-java/src/main/java/com/deepl/api/utils/BackoffTimer.java new file mode 100644 index 0000000..f082e51 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/utils/BackoffTimer.java @@ -0,0 +1,65 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api.utils; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ThreadLocalRandom; + +public class BackoffTimer { + + private int numRetries; + private Duration backoff; + private final Duration minTimeout; + private Instant deadline; + + private static final Duration backoffInitial = Duration.ofSeconds(1); + private static final Duration backoffMax = Duration.ofSeconds(120); + private static final float jitter = 0.23F; + private static final float multiplier = 1.6F; + + public BackoffTimer(Duration minTimeout) { + numRetries = 0; + backoff = backoffInitial; + this.minTimeout = minTimeout; + deadline = Instant.now().plus(backoff); + } + + public Duration getTimeout() { + Duration timeToDeadline = getTimeUntilDeadline(); + if (timeToDeadline.compareTo(minTimeout) < 0) return minTimeout; + return timeToDeadline; + } + + public long getTimeoutMillis() { + return getTimeout().toMillis(); + } + + public int getNumRetries() { + return numRetries; + } + + public void sleepUntilRetry() throws InterruptedException { + try { + Thread.sleep(getTimeUntilDeadline().toMillis()); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw exception; + } + + backoff = Duration.ofNanos((long) (backoff.toNanos() * multiplier)); + if (backoff.compareTo(backoffMax) > 0) backoff = backoffMax; + + float randomJitter = (ThreadLocalRandom.current().nextFloat() * 2.0F - 1.0F) * jitter + 1.0F; + Duration jitteredBackoff = Duration.ofNanos((long) (backoff.toNanos() * randomJitter)); + deadline = Instant.now().plus(jitteredBackoff); + ++numRetries; + } + + private Duration getTimeUntilDeadline() { + Instant currentTime = Instant.now(); + if (currentTime.isAfter(deadline)) return Duration.ZERO; + return Duration.between(currentTime, deadline); + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/utils/KeyValuePair.java b/deepl-java/src/main/java/com/deepl/api/utils/KeyValuePair.java new file mode 100644 index 0000000..6dcf923 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/utils/KeyValuePair.java @@ -0,0 +1,13 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api.utils; + +import java.util.AbstractMap; + +public class KeyValuePair extends AbstractMap.SimpleEntry { + + public KeyValuePair(K key, V value) { + super(key, value); + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/utils/NamedStream.java b/deepl-java/src/main/java/com/deepl/api/utils/NamedStream.java new file mode 100644 index 0000000..42ed981 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/utils/NamedStream.java @@ -0,0 +1,24 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api.utils; + +import java.io.*; + +public class NamedStream { + private final String fileName; + private final InputStream inputStream; + + public NamedStream(String fileName, InputStream inputStream) { + this.fileName = fileName; + this.inputStream = inputStream; + } + + public String getFileName() { + return fileName; + } + + public InputStream getInputStream() { + return inputStream; + } +} diff --git a/deepl-java/src/main/java/com/deepl/api/utils/StreamUtil.java b/deepl-java/src/main/java/com/deepl/api/utils/StreamUtil.java new file mode 100644 index 0000000..662f606 --- /dev/null +++ b/deepl-java/src/main/java/com/deepl/api/utils/StreamUtil.java @@ -0,0 +1,47 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api.utils; + +import java.io.*; +import java.nio.charset.*; + +public class StreamUtil { + public static final int DEFAULT_BUFFER_SIZE = 1024; + + public static String readStream(InputStream inputStream) throws IOException { + Charset charset = StandardCharsets.UTF_8; + final char[] buffer = new char[DEFAULT_BUFFER_SIZE]; + final StringBuilder sb = new StringBuilder(); + final Reader in = new BufferedReader(new InputStreamReader(inputStream, charset)); + int charsRead; + while ((charsRead = in.read(buffer, 0, DEFAULT_BUFFER_SIZE)) > 0) { + sb.append(buffer, 0, charsRead); + } + return sb.toString(); + } + + /** + * Reads all bytes from input stream and writes the bytes to the given output stream in the order + * that they are read. On return, input stream will be at end of stream. This method does not + * close either stream. + * + *

Implementation based on {@link InputStream#transferTo(OutputStream)} added in Java 9. + * + * @param inputStream The input stream, non-null. + * @param outputStream The output stream, non-null. + * @return Number of bytes transferred. + * @throws IOException if an I/O error occurs when reading or writing. + */ + public static long transferTo(InputStream inputStream, OutputStream outputStream) + throws IOException { + long transferred = 0; + final byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; + int read; + while ((read = inputStream.read(buffer, 0, DEFAULT_BUFFER_SIZE)) >= 0) { + outputStream.write(buffer, 0, read); + transferred += read; + } + return transferred; + } +} diff --git a/deepl-java/src/test/java/com/deepl/api/GeneralTest.java b/deepl-java/src/test/java/com/deepl/api/GeneralTest.java new file mode 100644 index 0000000..9912753 --- /dev/null +++ b/deepl-java/src/test/java/com/deepl/api/GeneralTest.java @@ -0,0 +1,229 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +import java.io.*; +import java.net.*; +import java.time.*; +import java.util.*; +import org.junit.jupiter.api.*; + +class GeneralTest extends TestBase { + + @Test + void testEmptyAuthKey() { + IllegalArgumentException thrown = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> { + Translator translator = new Translator(""); + }); + } + + @Test + void testInvalidAuthKey() { + String authKey = "invalid"; + Translator translator = new Translator(authKey); + Assertions.assertThrows(AuthorizationException.class, translator::getUsage); + } + + @Test + void testExampleTranslation() throws DeepLException, InterruptedException { + Translator translator = createTranslator(); + + for (Map.Entry entry : exampleText.entrySet()) { + String inputText = entry.getValue(); + String sourceLang = LanguageCode.removeRegionalVariant(entry.getKey()); + TextResult result = translator.translateText(inputText, sourceLang, "en-US"); + Assertions.assertTrue(result.getText().toLowerCase().contains("proton")); + } + } + + @Test + void testInvalidServerUrl() { + Assertions.assertThrows( + DeepLException.class, + () -> { + Translator translator = + new Translator(authKey, new TranslatorOptions().setServerUrl("http:/api.deepl.com")); + translator.getUsage(); + }); + } + + @Test + void testUsage() throws DeepLException, InterruptedException { + Translator translator = createTranslator(); + Usage usage = translator.getUsage(); + Assertions.assertTrue(usage.toString().contains("Usage this billing period")); + } + + @Test + void testGetSourceAndTargetLanguages() throws DeepLException, InterruptedException { + Translator translator = createTranslator(); + List sourceLanguages = translator.getSourceLanguages(); + List targetLanguages = translator.getTargetLanguages(); + + for (Language language : sourceLanguages) { + if (Objects.equals(language.getCode(), "en")) { + Assertions.assertEquals("English", language.getName()); + } + Assertions.assertNull(language.getSupportsFormality()); + } + Assertions.assertTrue(sourceLanguages.size() > 20); + + for (Language language : targetLanguages) { + Assertions.assertNotNull(language.getSupportsFormality()); + if (Objects.equals(language.getCode(), "de")) { + Assertions.assertTrue(language.getSupportsFormality()); + Assertions.assertEquals("German", language.getName()); + } + } + Assertions.assertTrue(targetLanguages.size() > 20); + } + + @Test + void testAuthKeyIsFreeAccount() { + Assertions.assertTrue( + Translator.isFreeAccountAuthKey("b493b8ef-0176-215d-82fe-e28f182c9544:fx")); + Assertions.assertFalse(Translator.isFreeAccountAuthKey("b493b8ef-0176-215d-82fe-e28f182c9544")); + } + + @Test + void testProxyUsage() throws DeepLException, InterruptedException, MalformedURLException { + Assumptions.assumeTrue(isMockProxyServer); + SessionOptions sessionOptions = new SessionOptions(); + sessionOptions.expectProxy = true; + Map headers = sessionOptions.createSessionHeaders(); + + URL proxyUrl = new URL(TestBase.proxyUrl); + TranslatorOptions options = + new TranslatorOptions() + .setProxy( + new Proxy( + Proxy.Type.HTTP, new InetSocketAddress(proxyUrl.getHost(), proxyUrl.getPort()))) + .setHeaders(headers) + .setServerUrl(serverUrl); + Translator translator = new Translator(authKey, options); + translator.getUsage(); + } + + @Test + void testUsageNoResponse() { + Assumptions.assumeTrue(isMockServer); + // Lower the retry count and timeout for this test + Translator translator = + createTranslator( + new SessionOptions().setNoResponse(2), + new TranslatorOptions().setMaxRetries(0).setTimeout(Duration.ofMillis(1))); + + Assertions.assertThrows(ConnectionException.class, translator::getUsage); + } + + @Test + void testTranslateTooManyRequests() { + Assumptions.assumeTrue(isMockServer); + // Lower the retry count and timeout for this test + Translator translator = + createTranslator( + new SessionOptions().setRespondWith429(2), new TranslatorOptions().setMaxRetries(0)); + + Assertions.assertThrows( + TooManyRequestsException.class, + () -> translator.translateText(exampleText.get("en"), null, "DE")); + } + + @Test + void testUsageOverrun() throws DeepLException, InterruptedException, IOException { + Assumptions.assumeTrue(isMockServer); + int characterLimit = 20; + int documentLimit = 1; + // Lower the retry count and timeout for this test + Translator translator = + createTranslator( + new SessionOptions() + .setInitCharacterLimit(characterLimit) + .setInitDocumentLimit(documentLimit) + .withRandomAuthKey(), + new TranslatorOptions().setMaxRetries(0).setTimeout(Duration.ofMillis(1))); + + Usage usage = translator.getUsage(); + Assertions.assertNotNull(usage.getCharacter()); + Assertions.assertNotNull(usage.getDocument()); + Assertions.assertNull(usage.getTeamDocument()); + Assertions.assertEquals(0, usage.getCharacter().getCount()); + Assertions.assertEquals(0, usage.getDocument().getCount()); + Assertions.assertEquals(characterLimit, usage.getCharacter().getLimit()); + Assertions.assertEquals(documentLimit, usage.getDocument().getLimit()); + Assertions.assertTrue(usage.toString().contains("Characters: 0 of 20")); + Assertions.assertTrue(usage.toString().contains("Documents: 0 of 1")); + + File inputFile = createInputFile(); + writeToFile(inputFile, repeatString("a", characterLimit)); + File outputFile = createOutputFile(); + + translator.translateDocument(inputFile, outputFile, null, "de"); + + usage = translator.getUsage(); + Assertions.assertTrue(usage.anyLimitReached()); + Assertions.assertNotNull(usage.getCharacter()); + Assertions.assertNotNull(usage.getDocument()); + Assertions.assertTrue(usage.getDocument().limitReached()); + Assertions.assertTrue(usage.getCharacter().limitReached()); + + Assertions.assertThrows( + IOException.class, + () -> { + translator.translateDocument(inputFile, outputFile, null, "de"); + }); + outputFile.delete(); + + DocumentTranslationException thrownDeepLException = + Assertions.assertThrows( + DocumentTranslationException.class, + () -> { + translator.translateDocument(inputFile, outputFile, null, "de"); + }); + Assertions.assertNull(thrownDeepLException.getHandle()); + Assertions.assertEquals( + QuotaExceededException.class, thrownDeepLException.getCause().getClass()); + + Assertions.assertThrows( + QuotaExceededException.class, + () -> { + translator.translateText(exampleText.get("en"), null, "de"); + }); + } + + @Test + void testUsageTeamDocumentLimit() throws Exception { + Assumptions.assumeTrue(isMockServer); + int teamDocumentLimit = 1; + Translator translator = + createTranslator( + new SessionOptions() + .setInitCharacterLimit(0) + .setInitDocumentLimit(0) + .setInitTeamDocumentLimit(teamDocumentLimit) + .withRandomAuthKey()); + + Usage usage = translator.getUsage(); + Assertions.assertNull(usage.getCharacter()); + Assertions.assertNull(usage.getDocument()); + Assertions.assertNotNull(usage.getTeamDocument()); + Assertions.assertEquals(0, usage.getTeamDocument().getCount()); + Assertions.assertEquals(teamDocumentLimit, usage.getTeamDocument().getLimit()); + Assertions.assertTrue(usage.toString().contains("Team documents: 0 of 1")); + + File inputFile = createInputFile(); + writeToFile(inputFile, "a"); + File outputFile = createOutputFile(); + + translator.translateDocument(inputFile, outputFile, null, "de"); + + usage = translator.getUsage(); + Assertions.assertTrue(usage.anyLimitReached()); + Assertions.assertNotNull(usage.getTeamDocument()); + Assertions.assertTrue(usage.getTeamDocument().limitReached()); + } +} diff --git a/deepl-java/src/test/java/com/deepl/api/SessionOptions.java b/deepl-java/src/test/java/com/deepl/api/SessionOptions.java new file mode 100644 index 0000000..83d0116 --- /dev/null +++ b/deepl-java/src/test/java/com/deepl/api/SessionOptions.java @@ -0,0 +1,118 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class SessionOptions { + // Mock server session options + public Integer noResponse; + public Integer respondWith429; + public Integer initCharacterLimit; + public Integer initDocumentLimit; + public Integer initTeamDocumentLimit; + public Integer documentFailure; + public Duration documentQueueTime; + public Duration documentTranslateTime; + public Boolean expectProxy; + + public boolean randomAuthKey; + + SessionOptions() { + randomAuthKey = false; + } + + public Map createSessionHeaders() { + Map headers = new HashMap<>(); + + String uuid = UUID.randomUUID().toString(); + headers.put("mock-server-session", "deepl-java-test/" + uuid); + + if (noResponse != null) { + headers.put("mock-server-session-no-response-count", noResponse.toString()); + } + if (respondWith429 != null) { + headers.put("mock-server-session-429-count", respondWith429.toString()); + } + if (initCharacterLimit != null) { + headers.put("mock-server-session-init-character-limit", initCharacterLimit.toString()); + } + if (initDocumentLimit != null) { + headers.put("mock-server-session-init-document-limit", initDocumentLimit.toString()); + } + if (initTeamDocumentLimit != null) { + headers.put("mock-server-session-init-team-document-limit", initTeamDocumentLimit.toString()); + } + if (documentFailure != null) { + headers.put("mock-server-session-doc-failure", documentFailure.toString()); + } + if (documentQueueTime != null) { + headers.put( + "mock-server-session-doc-queue-time", Long.toString(documentQueueTime.toMillis())); + } + if (documentTranslateTime != null) { + headers.put( + "mock-server-session-doc-translate-time", + Long.toString(documentTranslateTime.toMillis())); + } + if (expectProxy != null) { + headers.put("mock-server-session-expect-proxy", expectProxy ? "1" : "0"); + } + + return headers; + } + + public SessionOptions setNoResponse(int noResponse) { + this.noResponse = noResponse; + return this; + } + + public SessionOptions setRespondWith429(int respondWith429) { + this.respondWith429 = respondWith429; + return this; + } + + public SessionOptions setInitCharacterLimit(int initCharacterLimit) { + this.initCharacterLimit = initCharacterLimit; + return this; + } + + public SessionOptions setInitDocumentLimit(int initDocumentLimit) { + this.initDocumentLimit = initDocumentLimit; + return this; + } + + public SessionOptions setInitTeamDocumentLimit(int initTeamDocumentLimit) { + this.initTeamDocumentLimit = initTeamDocumentLimit; + return this; + } + + public SessionOptions setDocumentFailure(int documentFailure) { + this.documentFailure = documentFailure; + return this; + } + + public SessionOptions setDocumentQueueTime(Duration documentQueueTime) { + this.documentQueueTime = documentQueueTime; + return this; + } + + public SessionOptions setDocumentTranslateTime(Duration documentTranslateTime) { + this.documentTranslateTime = documentTranslateTime; + return this; + } + + public SessionOptions setExpectProxy(boolean expectProxy) { + this.expectProxy = expectProxy; + return this; + } + + public SessionOptions withRandomAuthKey() { + this.randomAuthKey = true; + return this; + } +} diff --git a/deepl-java/src/test/java/com/deepl/api/TestBase.java b/deepl-java/src/test/java/com/deepl/api/TestBase.java new file mode 100644 index 0000000..b0c6664 --- /dev/null +++ b/deepl-java/src/test/java/com/deepl/api/TestBase.java @@ -0,0 +1,177 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +import com.deepl.api.utils.*; +import java.io.*; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class TestBase { + protected static final boolean isMockServer; + protected static final boolean isMockProxyServer; + protected static final String authKey; + protected static final String serverUrl; + protected static final String proxyUrl; + + protected static final Map exampleText; + + private static final String tempDirBase; + + final String exampleInput = exampleText.get("en"); + final String exampleLargeInput = repeatString(exampleText.get("en") + "\n", 1000); + final String exampleOutput = exampleText.get("de"); + final String exampleLargeOutput = repeatString(exampleText.get("de") + "\n", 1000); + final String tempDir; + + static { + isMockServer = System.getenv("DEEPL_MOCK_SERVER_PORT") != null; + serverUrl = System.getenv("DEEPL_SERVER_URL"); + proxyUrl = System.getenv("DEEPL_PROXY_URL"); + isMockProxyServer = proxyUrl != null; + if (isMockServer) { + authKey = "mock_server"; + if (serverUrl == null) { + System.err.println( + "DEEPL_SERVER_URL environment variable must be set when using mock server."); + System.exit(1); + } + } else { + authKey = System.getenv("DEEPL_AUTH_KEY"); + if (authKey == null) { + System.err.println( + "DEEPL_AUTH_KEY environment variable must be set unless using mock server."); + System.exit(1); + } + } + + exampleText = new HashMap<>(); + exampleText.put("bg", "протонен лъч"); + exampleText.put("cs", "protonový paprsek"); + exampleText.put("da", "protonstråle"); + exampleText.put("de", "Protonenstrahl"); + exampleText.put("el", "δέσμη πρωτονίων"); + exampleText.put("en", "proton beam"); + exampleText.put("en-US", "proton beam"); + exampleText.put("en-GB", "proton beam"); + exampleText.put("es", "haz de protones"); + exampleText.put("et", "prootonikiirgus"); + exampleText.put("fi", "protonisäde"); + exampleText.put("fr", "faisceau de protons"); + exampleText.put("hu", "protonnyaláb"); + exampleText.put("id", "berkas proton"); + exampleText.put("it", "fascio di protoni"); + exampleText.put("ja", "陽子ビーム"); + exampleText.put("lt", "protonų spindulys"); + exampleText.put("lv", "protonu staru kūlis"); + exampleText.put("nl", "protonenbundel"); + exampleText.put("pl", "wiązka protonów"); + exampleText.put("pt", "feixe de prótons"); + exampleText.put("pt-BR", "feixe de prótons"); + exampleText.put("pt-PT", "feixe de prótons"); + exampleText.put("ro", "fascicul de protoni"); + exampleText.put("ru", "протонный луч"); + exampleText.put("sk", "protónový lúč"); + exampleText.put("sl", "protonski žarek"); + exampleText.put("sv", "protonstråle"); + exampleText.put("tr", "proton ışını"); + exampleText.put("zh", "质子束"); + + String tmpdir = System.getProperty("java.io.tmpdir"); + tempDirBase = tmpdir.endsWith("/") ? tmpdir : tmpdir + "/"; + } + + protected TestBase() { + tempDir = createTempDir(); + } + + protected Translator createTranslator() { + SessionOptions sessionOptions = new SessionOptions(); + return createTranslator(sessionOptions); + } + + protected Translator createTranslator(SessionOptions sessionOptions) { + TranslatorOptions translatorOptions = new TranslatorOptions(); + return createTranslator(sessionOptions, translatorOptions); + } + + protected Translator createTranslator( + SessionOptions sessionOptions, TranslatorOptions translatorOptions) { + Map headers = sessionOptions.createSessionHeaders(); + + if (translatorOptions.getServerUrl() == null) { + translatorOptions.setServerUrl(serverUrl); + } + + if (translatorOptions.getHeaders() != null) { + headers.putAll(translatorOptions.getHeaders()); + } + translatorOptions.setHeaders(headers); + + String authKey = sessionOptions.randomAuthKey ? UUID.randomUUID().toString() : TestBase.authKey; + + try { + return new Translator(authKey, translatorOptions); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + System.exit(1); + return null; + } + } + + protected String createTempDir() { + String newTempDir = tempDirBase + UUID.randomUUID(); + boolean created = new File(newTempDir).mkdirs(); + return newTempDir; + } + + protected void writeToFile(File file, String content) throws IOException { + Boolean justCreated = file.createNewFile(); + FileWriter writer = new FileWriter(file); + writer.write(content); + writer.flush(); + writer.close(); + } + + protected String readFromFile(File file) throws IOException { + if (!file.exists()) return ""; + return StreamUtil.readStream(new FileInputStream(file)); + } + + /** + * Returns a string containing the input string repeated given number of times. Note: + * String.repeat() was added in Java 11. + * + * @param input Input string to be repeated. + * @param number Number of times to repeat string. + * @return Input string repeated given number of times. + */ + protected static String repeatString(String input, int number) { + StringBuilder sb = new StringBuilder(input.length() * number); + for (int i = 0; i < number; i++) { + sb.append(input); + } + return sb.toString(); + } + + protected File createInputFile() throws IOException { + return createInputFile(exampleInput); + } + + protected File createInputFile(String content) throws IOException { + File inputFile = new File(tempDir + "/example_document.txt"); + inputFile.delete(); + inputFile.createNewFile(); + writeToFile(inputFile, content); + return inputFile; + } + + protected File createOutputFile() { + File outputFile = new File(tempDir + "/output/example_document.txt"); + new File(outputFile.getParent()).mkdir(); + outputFile.delete(); + return outputFile; + } +} diff --git a/deepl-java/src/test/java/com/deepl/api/TranslateDocumentTest.java b/deepl-java/src/test/java/com/deepl/api/TranslateDocumentTest.java new file mode 100644 index 0000000..f39b8f8 --- /dev/null +++ b/deepl-java/src/test/java/com/deepl/api/TranslateDocumentTest.java @@ -0,0 +1,227 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import java.util.Date; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +public class TranslateDocumentTest extends TestBase { + @Test + void testTranslateDocument() throws Exception { + Translator translator = createTranslator(); + + File inputFile = createInputFile(); + File outputFile = createOutputFile(); + + translator.translateDocument(inputFile, outputFile, "en", "de"); + Assertions.assertEquals(exampleOutput, readFromFile(outputFile)); + + // Test with output path occupied + Assertions.assertThrows( + IOException.class, + () -> { + translator.translateDocument(inputFile, outputFile, "en", "de"); + }); + } + + @Test + void testTranslateDocumentFailsWithOutputOccupied() throws Exception { + Translator translator = createTranslator(); + + File inputFile = createInputFile(); + File outputFile = createOutputFile(); + outputFile.createNewFile(); + + // Test with output path occupied + Assertions.assertThrows( + IOException.class, + () -> { + translator.translateDocument(inputFile, outputFile, "en", "de"); + }); + } + + @Test + void testTranslateDocumentWithRetry() throws Exception { + Assumptions.assumeTrue(isMockServer); + Translator translator = + createTranslator( + new SessionOptions().setNoResponse(1), + new TranslatorOptions().setTimeout(Duration.ofSeconds(1))); + + File outputFile = createOutputFile(); + translator.translateDocument(createInputFile(), outputFile, "en", "de"); + Assertions.assertEquals(exampleOutput, readFromFile(outputFile)); + } + + @Test + void testTranslateDocumentWithWaiting() throws Exception { + Assumptions.assumeTrue(isMockServer); + Translator translator = + createTranslator( + new SessionOptions() + .setDocumentTranslateTime(Duration.ofSeconds(2)) + .setDocumentQueueTime(Duration.ofSeconds(2))); + File outputFile = createOutputFile(); + translator.translateDocument(createInputFile(), outputFile, "en", "de"); + Assertions.assertEquals(exampleOutput, readFromFile(outputFile)); + } + + @Test + void testTranslateLargeDocument() throws Exception { + Assumptions.assumeTrue(isMockServer); + Translator translator = createTranslator(); + File inputFile = createInputFile(exampleLargeInput); + File outputFile = createOutputFile(); + translator.translateDocument(inputFile, outputFile, "en", "de"); + Assertions.assertEquals(exampleLargeOutput, readFromFile(outputFile)); + } + + @Test + void testTranslateDocumentFormality() throws Exception { + Translator translator = createTranslator(); + File inputFile = createInputFile("How are you?"); + File outputFile = createOutputFile(); + translator.translateDocument( + inputFile, + outputFile, + "en", + "de", + new DocumentTranslationOptions().setFormality(Formality.More)); + if (!isMockServer) { + Assertions.assertEquals("Wie geht es Ihnen?", readFromFile(outputFile)); + } + + outputFile.delete(); + + translator.translateDocument( + inputFile, + outputFile, + "en", + "de", + new DocumentTranslationOptions().setFormality(Formality.Less)); + if (!isMockServer) { + Assertions.assertEquals("Wie geht es dir?", readFromFile(outputFile)); + } + } + + @Test + void testTranslateDocumentFailureDuringTranslation() throws Exception { + Translator translator = createTranslator(); + + // Translating text from DE to DE will trigger error + File inputFile = createInputFile(exampleText.get("de")); + File outputFile = createOutputFile(); + + DocumentTranslationException exception = + Assertions.assertThrows( + DocumentTranslationException.class, + () -> { + translator.translateDocument(inputFile, outputFile, null, "de"); + }); + Assertions.assertTrue(exception.getMessage().contains("Source and target language")); + } + + @Test + void testInvalidDocument() throws Exception { + Translator translator = createTranslator(); + File inputFile = new File(tempDir + "/document.xyz"); + writeToFile(inputFile, exampleText.get("en")); + File outputFile = new File(tempDir + "/output_document.xyz"); + outputFile.delete(); + + DocumentTranslationException exception = + Assertions.assertThrows( + DocumentTranslationException.class, + () -> { + translator.translateDocument(inputFile, outputFile, "en", "de"); + }); + Assertions.assertNull(exception.getHandle()); + } + + @Test + void testTranslateDocumentLowLevel() throws Exception { + Assumptions.assumeTrue(isMockServer); + // Set a small document queue time to attempt downloading a queued document + Translator translator = + createTranslator(new SessionOptions().setDocumentQueueTime(Duration.ofMillis(100))); + + File inputFile = createInputFile(); + File outputFile = createOutputFile(); + final DocumentHandle handle = translator.translateDocumentUpload(inputFile, "en", "de"); + + DocumentStatus status = translator.translateDocumentStatus(handle); + Assertions.assertEquals(handle.getDocumentId(), status.getDocumentId()); + Assertions.assertTrue(status.ok()); + Assertions.assertFalse(status.done()); + + // Downloading before document is ready will fail + Assertions.assertThrows( + DocumentNotReadyException.class, + () -> { + translator.translateDocumentDownload(handle, outputFile); + }); + // Output file should not exist in case of failure + Assertions.assertFalse(outputFile.exists()); + + // Test recreating a document handle from id & key + String documentId = handle.getDocumentId(); + String documentKey = handle.getDocumentKey(); + DocumentHandle recreatedHandle = new DocumentHandle(documentId, documentKey); + status = translator.translateDocumentStatus(recreatedHandle); + Assertions.assertTrue(status.ok()); + + while (status.ok() && !status.done()) { + Thread.sleep(200); + status = translator.translateDocumentStatus(recreatedHandle); + } + + Assertions.assertTrue(status.ok() && status.done()); + translator.translateDocumentDownload(recreatedHandle, outputFile); + Assertions.assertEquals(exampleOutput, readFromFile(outputFile)); + } + + @Test + void testTranslateDocumentRequestFields() throws Exception { + Assumptions.assumeTrue(isMockServer); + Translator translator = + createTranslator( + new SessionOptions() + .setDocumentTranslateTime(Duration.ofSeconds(2)) + .setDocumentQueueTime(Duration.ofSeconds(2))); + File inputFile = createInputFile(); + File outputFile = createOutputFile(); + + long timeBefore = new Date().getTime(); + DocumentHandle handle = translator.translateDocumentUpload(inputFile, "en", "de"); + DocumentStatus status = translator.translateDocumentStatus(handle); + Assertions.assertTrue(status.ok()); + Assertions.assertTrue( + status.getSecondsRemaining() == null || status.getSecondsRemaining() >= 0); + status = translator.translateDocumentWaitUntilDone(handle); + translator.translateDocumentDownload(handle, outputFile); + long timeAfter = new Date().getTime(); + + Assertions.assertEquals(exampleInput.length(), status.getBilledCharacters()); + Assertions.assertTrue(timeAfter - timeBefore > 4000); + Assertions.assertEquals(exampleOutput, readFromFile(outputFile)); + } + + @Test + void testRecreateDocumentHandleInvalid() { + Translator translator = createTranslator(); + String documentId = repeatString("12AB", 8); + String documentKey = repeatString("CD34", 16); + DocumentHandle handle = new DocumentHandle(documentId, documentKey); + Assertions.assertThrows( + NotFoundException.class, + () -> { + translator.translateDocumentStatus(handle); + }); + } +} diff --git a/deepl-java/src/test/java/com/deepl/api/TranslateTextTest.java b/deepl-java/src/test/java/com/deepl/api/TranslateTextTest.java new file mode 100644 index 0000000..57da312 --- /dev/null +++ b/deepl-java/src/test/java/com/deepl/api/TranslateTextTest.java @@ -0,0 +1,296 @@ +// Copyright 2022 DeepL SE (https://www.deepl.com) +// Use of this source code is governed by an MIT +// license that can be found in the LICENSE file. +package com.deepl.api; + +import java.util.*; +import java.util.function.Consumer; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +public class TranslateTextTest extends TestBase { + + @Test + void testSingleText() throws DeepLException, InterruptedException { + Translator translator = createTranslator(); + TextResult result = translator.translateText(exampleText.get("en"), null, LanguageCode.German); + Assertions.assertEquals(exampleText.get("de"), result.getText()); + Assertions.assertEquals("en", result.getDetectedSourceLanguage()); + } + + @Test + void testTextArray() throws DeepLException, InterruptedException { + Translator translator = createTranslator(); + List texts = new ArrayList<>(); + texts.add(exampleText.get("fr")); + texts.add(exampleText.get("en")); + List result = translator.translateText(texts, null, LanguageCode.German); + Assertions.assertEquals(exampleText.get("de"), result.get(0).getText()); + Assertions.assertEquals(exampleText.get("de"), result.get(1).getText()); + } + + @Test + void testSourceLang() throws DeepLException, InterruptedException { + Consumer checkResult = + (result) -> { + Assertions.assertEquals(exampleText.get("de"), result.getText()); + Assertions.assertEquals("en", result.getDetectedSourceLanguage()); + }; + + Translator translator = createTranslator(); + checkResult.accept(translator.translateText(exampleText.get("en"), null, "DE")); + checkResult.accept(translator.translateText(exampleText.get("en"), "En", "DE")); + checkResult.accept(translator.translateText(exampleText.get("en"), "en", "DE")); + + List sourceLanguages = translator.getSourceLanguages(); + Language sourceLanguageEn = + sourceLanguages.stream() + .filter((language -> Objects.equals(language.getCode(), "en"))) + .findFirst() + .orElse(null); + Language sourceLanguageDe = + sourceLanguages.stream() + .filter((language -> Objects.equals(language.getCode(), "de"))) + .findFirst() + .orElse(null); + Assertions.assertNotNull(sourceLanguageEn); + Assertions.assertNotNull(sourceLanguageDe); + checkResult.accept( + translator.translateText(exampleText.get("en"), sourceLanguageEn, sourceLanguageDe)); + } + + @Test + void testTargetLang() throws DeepLException, InterruptedException { + Consumer checkResult = + (result) -> { + Assertions.assertEquals(exampleText.get("de"), result.getText()); + Assertions.assertEquals("en", result.getDetectedSourceLanguage()); + }; + + Translator translator = createTranslator(); + checkResult.accept(translator.translateText(exampleText.get("en"), null, "De")); + checkResult.accept(translator.translateText(exampleText.get("en"), null, "de")); + checkResult.accept(translator.translateText(exampleText.get("en"), null, "DE")); + + List targetLanguages = translator.getTargetLanguages(); + Language targetLanguageDe = + targetLanguages.stream() + .filter((language -> Objects.equals(language.getCode(), "de"))) + .findFirst() + .orElse(null); + Assertions.assertNotNull(targetLanguageDe); + checkResult.accept(translator.translateText(exampleText.get("en"), null, targetLanguageDe)); + + // Check that en and pt as target languages throw an exception + Assertions.assertThrows( + IllegalArgumentException.class, + () -> { + translator.translateText(exampleText.get("de"), null, "en"); + }); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> { + translator.translateText(exampleText.get("de"), null, "pt"); + }); + } + + @Test + void testInvalidLanguage() { + Translator translator = createTranslator(); + DeepLException thrown; + thrown = + Assertions.assertThrows( + DeepLException.class, + () -> { + translator.translateText(exampleText.get("en"), null, "XX"); + }); + Assertions.assertTrue(thrown.getMessage().contains("target_lang")); + + thrown = + Assertions.assertThrows( + DeepLException.class, + () -> { + translator.translateText(exampleText.get("en"), "XX", "de"); + }); + Assertions.assertTrue(thrown.getMessage().contains("source_lang")); + } + + @Test + void testTranslateWithRetries() throws DeepLException, InterruptedException { + Assumptions.assumeTrue(isMockServer); + Translator translator = createTranslator(new SessionOptions().setRespondWith429(2)); + + long timeBefore = new Date().getTime(); + List texts = new ArrayList<>(); + texts.add(exampleText.get("en")); + texts.add(exampleText.get("ja")); + List result = translator.translateText(texts, null, "de"); + long timeAfter = new Date().getTime(); + + Assertions.assertEquals(2, result.size()); + Assertions.assertEquals(exampleText.get("de"), result.get(0).getText()); + Assertions.assertEquals("en", result.get(0).getDetectedSourceLanguage()); + Assertions.assertEquals(exampleText.get("de"), result.get(1).getText()); + Assertions.assertEquals("ja", result.get(1).getDetectedSourceLanguage()); + Assertions.assertTrue(timeAfter - timeBefore > 1000); + } + + @Test + void testFormality() throws DeepLException, InterruptedException { + Translator translator = createTranslator(); + TextResult result; + + result = + translator.translateText( + "How are you?", null, "de", new TextTranslationOptions().setFormality(Formality.Less)); + if (!isMockServer) { + Assertions.assertEquals("Wie geht es dir?", result.getText()); + } + + result = + translator.translateText( + "How are you?", + null, + "de", + new TextTranslationOptions().setFormality(Formality.Default)); + if (!isMockServer) { + Assertions.assertEquals("Wie geht es Ihnen?", result.getText()); + } + + result = + translator.translateText( + "How are you?", null, "de", new TextTranslationOptions().setFormality(Formality.More)); + if (!isMockServer) { + Assertions.assertEquals("Wie geht es Ihnen?", result.getText()); + } + } + + @Test + void testSplitSentences() throws DeepLException, InterruptedException { + Assumptions.assumeTrue(isMockServer); + + Translator translator = createTranslator(); + String text = + "If the implementation is hard to explain, it's a bad idea.\nIf the implementation is easy to explain, it may be a good idea."; + + translator.translateText( + text, + null, + "de", + new TextTranslationOptions().setSentenceSplittingMode(SentenceSplittingMode.Off)); + translator.translateText( + text, + null, + "de", + new TextTranslationOptions().setSentenceSplittingMode(SentenceSplittingMode.All)); + translator.translateText( + text, + null, + "de", + new TextTranslationOptions().setSentenceSplittingMode(SentenceSplittingMode.NoNewlines)); + } + + @Test + void testPreserveFormatting() throws DeepLException, InterruptedException { + Assumptions.assumeTrue(isMockServer); + + Translator translator = createTranslator(); + translator.translateText( + exampleText.get("en"), + null, + "de", + new TextTranslationOptions().setPreserveFormatting(true)); + translator.translateText( + exampleText.get("en"), + null, + "de", + new TextTranslationOptions().setPreserveFormatting(false)); + } + + @Test + void testTagHandlingXML() throws DeepLException, InterruptedException { + Translator translator = createTranslator(); + String text = + "A document's title" + + "" + + "This is a sentence split" + + "across two <span> tags that should be treated as one." + + "" + + "" + + "Here is a sentence. Followed by a second one." + + "This sentence will not be translated." + + "" + + ""; + TextResult result = + translator.translateText( + text, + null, + "de", + new TextTranslationOptions() + .setTagHandling("xml") + .setOutlineDetection(false) + .setNonSplittingTags(Arrays.asList("span")) + .setSplittingTags(Arrays.asList("title", "par")) + .setIgnoreTags(Arrays.asList("raw"))); + if (!isMockServer) { + Assertions.assertTrue( + result.getText().contains("This sentence will not be translated.")); + Assertions.assertTrue(result.getText().matches(".*.*Der Titel.*.*")); + } + } + + @Test + void testTagHandlingHTML() throws DeepLException, InterruptedException { + Translator translator = createTranslator(); + String text = + "" + + "" + + "" + + "

My First Heading

" + + "

My first paragraph.

" + + "" + + ""; + + TextResult result = + translator.translateText( + text, null, "de", new TextTranslationOptions().setTagHandling("html")); + if (!isMockServer) { + Assertions.assertTrue(result.getText().contains("

Meine erste Überschrift

")); + Assertions.assertTrue( + result.getText().contains("

My first paragraph.

")); + } + } + + @Test + void testEmptyText() { + Translator translator = createTranslator(); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> { + translator.translateText("", null, "de"); + }); + } + + @Test + void testMixedCaseLanguages() throws DeepLException, InterruptedException { + Translator translator = createTranslator(); + TextResult result; + + result = translator.translateText(exampleText.get("de"), null, "en-us"); + Assertions.assertEquals(exampleText.get("en-US"), result.getText().toLowerCase()); + Assertions.assertEquals("de", result.getDetectedSourceLanguage()); + + result = translator.translateText(exampleText.get("de"), null, "EN-us"); + Assertions.assertEquals(exampleText.get("en-US"), result.getText().toLowerCase()); + Assertions.assertEquals("de", result.getDetectedSourceLanguage()); + + result = translator.translateText(exampleText.get("de"), "de", "EN-US"); + Assertions.assertEquals(exampleText.get("en-US"), result.getText().toLowerCase()); + Assertions.assertEquals("de", result.getDetectedSourceLanguage()); + + result = translator.translateText(exampleText.get("de"), "dE", "EN-US"); + Assertions.assertEquals(exampleText.get("en-US"), result.getText().toLowerCase()); + Assertions.assertEquals("de", result.getDetectedSourceLanguage()); + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..656f2e6 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +org.gradle.jvmargs=-Dfile.encoding=UTF-8 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..41d9927 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..aa991fc --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..37af439 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,3 @@ + +rootProject.name = "deepl-java" +include("deepl-java")