Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.01% covered (success)
91.01%
81 / 89
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
DeepLTranslationProvider
91.01% covered (success)
91.01%
81 / 89
50.00% covered (danger)
50.00%
4 / 8
24.42
0.00% covered (danger)
0.00%
0 / 1
 getProviderName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getApiUrl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 doTranslate
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
5.03
 doTranslateBatch
87.50% covered (success)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
5.05
 translateBatch
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 supportsLanguagePair
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getSupportedLanguages
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
1
 mapLanguageCode
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
6.60
1<?php
2
3declare(strict_types=1);
4
5/**
6 * DeepL Translation API provider.
7 *
8 * This Source Code Form is subject to the terms of the Mozilla Public License,
9 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
10 * obtain one at https://mozilla.org/MPL/2.0/.
11 *
12 * @package   phpMyFAQ
13 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
14 * @copyright 2026 phpMyFAQ Team
15 * @license   http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
16 * @link      https://www.phpmyfaq.de
17 * @since     2026-01-17
18 */
19
20namespace phpMyFAQ\Translation\Provider;
21
22use Exception;
23use phpMyFAQ\Translation\AbstractTranslationProvider;
24use phpMyFAQ\Translation\Exception\ApiException;
25use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
26use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
27
28/**
29 * Class DeepLTranslationProvider
30 *
31 * DeepL Translation API implementation.
32 */
33class DeepLTranslationProvider extends AbstractTranslationProvider
34{
35    private const string API_URL_FREE = 'https://api-free.deepl.com/v2/translate';
36    private const string API_URL_PRO = 'https://api.deepl.com/v2/translate';
37
38    /**
39     * @inheritDoc
40     */
41    public function getProviderName(): string
42    {
43        return 'DeepL';
44    }
45
46    /**
47     * Get the appropriate API URL based on configuration.
48     *
49     * @return string API URL
50     */
51    private function getApiUrl(): string
52    {
53        $useFreeApi = $this->configuration->get('translation.deeplUseFreeApi');
54        return $useFreeApi === 'true' || $useFreeApi === true ? self::API_URL_FREE : self::API_URL_PRO;
55    }
56
57    /**
58     * @inheritDoc
59     */
60    protected function doTranslate(string $text, string $sourceLang, string $targetLang): string
61    {
62        $apiKey = (string) ($this->configuration->get('translation.deeplApiKey') ?? '');
63
64        if ($apiKey === '') {
65            throw new ApiException('DeepL API key not configured');
66        }
67
68        try {
69            $response = $this->httpClient->request('POST', $this->getApiUrl(), [
70                'headers' => [
71                    'Authorization' => 'DeepL-Auth-Key ' . $apiKey,
72                    'Content-Type' => 'application/x-www-form-urlencoded',
73                ],
74                'body' => [
75                    'text' => $text,
76                    'source_lang' => $this->mapLanguageCode($sourceLang),
77                    'target_lang' => $this->mapLanguageCode($targetLang),
78                ],
79            ]);
80
81            $data = $response->toArray();
82            $translations = $data['translations'] ?? [];
83            $firstTranslation = is_array($translations) ? $translations[0] ?? [] : [];
84
85            return is_array($firstTranslation) ? (string) ($firstTranslation['text'] ?? '') : '';
86        } catch (DecodingExceptionInterface|Exception|TransportExceptionInterface $e) {
87            throw new ApiException('DeepL API error: ' . $e->getMessage());
88        }
89    }
90
91    /**
92     * @inheritDoc
93     */
94    protected function doTranslateBatch(array $texts, string $sourceLang, string $targetLang): array
95    {
96        $apiKey = (string) ($this->configuration->get('translation.deeplApiKey') ?? '');
97
98        if ($apiKey === '') {
99            throw new ApiException('DeepL API key not configured');
100        }
101
102        try {
103            $response = $this->httpClient->request('POST', $this->getApiUrl(), [
104                'headers' => [
105                    'Authorization' => 'DeepL-Auth-Key ' . $apiKey,
106                    'Content-Type' => 'application/x-www-form-urlencoded',
107                ],
108                'body' => [
109                    'text' => $texts,
110                    'source_lang' => $this->mapLanguageCode($sourceLang),
111                    'target_lang' => $this->mapLanguageCode($targetLang),
112                ],
113            ]);
114
115            $data = $response->toArray();
116            $translations = $data['translations'] ?? [];
117
118            return array_map(
119                static fn(mixed $translation): string => is_array($translation)
120                    ? (string) ($translation['text'] ?? '')
121                    : '',
122                is_array($translations) ? array_values($translations) : [],
123            );
124        } catch (DecodingExceptionInterface|Exception|TransportExceptionInterface $e) {
125            throw new ApiException('DeepL API error: ' . $e->getMessage());
126        }
127    }
128
129    /**
130     * @inheritDoc
131     */
132    public function translateBatch(
133        array $texts,
134        string $sourceLang,
135        string $targetLang,
136        bool $preserveHtml = false,
137    ): array {
138        if ($preserveHtml) {
139            // Process each text individually with HTML preservation
140            return array_map(fn($text) => $this->translate($text, $sourceLang, $targetLang, true), $texts);
141        }
142
143        return $this->doTranslateBatch($texts, $sourceLang, $targetLang);
144    }
145
146    /**
147     * @inheritDoc
148     */
149    public function supportsLanguagePair(string $sourceLang, string $targetLang): bool
150    {
151        // DeepL has limited language support
152        $supported = $this->getSupportedLanguages();
153        return in_array($sourceLang, $supported, strict: true) && in_array($targetLang, $supported, strict: true);
154    }
155
156    /**
157     * @inheritDoc
158     */
159    public function getSupportedLanguages(): array
160    {
161        // Languages supported by DeepL
162        return [
163            'ar',
164            'bg',
165            'cs',
166            'da',
167            'de',
168            'el',
169            'en',
170            'es',
171            'et',
172            'fi',
173            'fr',
174            'hu',
175            'id',
176            'it',
177            'ja',
178            'ko',
179            'lt',
180            'lv',
181            'nb',
182            'nl',
183            'pl',
184            'pt',
185            'ro',
186            'ru',
187            'sk',
188            'sl',
189            'sv',
190            'tr',
191            'uk',
192            'zh',
193        ];
194    }
195
196    /**
197     * @inheritDoc
198     */
199    protected function mapLanguageCode(string $pmfLangCode): string
200    {
201        // DeepL uses uppercase language codes with regional variants
202        return match ($pmfLangCode) {
203            'en' => 'EN-US',
204            'pt' => 'PT-BR',
205            'zh' => 'ZH',
206            default => strtoupper($pmfLangCode),
207        };
208    }
209}