xref: /plugin/deeplautotranslate/action.php (revision 057940f7309e2caa06fb429f91e91494cd5ceadc)
1<?php
2/**
3 * Deepl Autotranslate Plugin
4 *
5 * @author     Jennifer Graul <me@netali.de>
6 */
7
8if(!defined('DOKU_INC')) die();
9
10use \dokuwiki\HTTP\DokuHTTPClient;
11use \dokuwiki\plugin\deeplautotranslate\MenuItem;
12
13class action_plugin_deeplautotranslate extends DokuWiki_Action_Plugin {
14
15    // manual mapping of ISO-languages to DeepL-languages to fix inconsistent naming
16    private $langs = [
17        'bg' => 'BG',
18        'cs' => 'CS',
19        'da' => 'DA',
20        'de' => 'DE',
21        'de-informal' => 'DE',
22        'el' => 'EL',
23        'en' => 'EN-GB',
24        'es' => 'ES',
25        'et' => 'ET',
26        'fi' => 'FI',
27        'fr' => 'FR',
28        'hu' => 'HU',
29        'hu-formal' => 'HU',
30        'it' => 'IT',
31        'ja' => 'JA',
32        'lt' => 'LT',
33        'lv' => 'LV',
34        'nl' => 'NL',
35        'pl' => 'PL',
36        'pt' => 'PT-PT',
37        'ro' => 'RO',
38        'ru' => 'RU',
39        'sk' => 'SK',
40        'sl' => 'SL',
41        'sv' => 'SV',
42        'zh' => 'ZH'
43    ];
44
45    /**
46     * Register its handlers with the DokuWiki's event controller
47     */
48    public function register(Doku_Event_Handler $controller) {
49        $controller->register_hook('ACTION_ACT_PREPROCESS','BEFORE', $this, 'preprocess');
50        $controller->register_hook('COMMON_PAGETPL_LOAD','AFTER', $this, 'autotrans_editor');
51        $controller->register_hook('MENU_ITEMS_ASSEMBLY', 'AFTER', $this, 'add_menu_button');
52    }
53
54    public function add_menu_button(Doku_Event $event) {
55        global $ID;
56        global $ACT;
57
58        if ($ACT != 'show') return;
59
60        if ($event->data['view'] != 'page') return;
61
62        if (!$this->getConf('show_button')) return;
63
64        $split_id = explode(':', $ID);
65        $lang_ns = array_shift($split_id);
66        // check if we are in a language namespace
67        if (array_key_exists($lang_ns, $this->langs)) {
68            // in language namespace --> check if we should translate
69            if (!$this->check_do_translation(true)) return;
70        } else {
71            // not in language namespace --> check if we should show the push translate button
72            if (!$this->check_do_push_translate()) return;
73        }
74
75        array_splice($event->data['items'], -1, 0, [new MenuItem()]);
76    }
77
78    public function preprocess(Doku_Event  $event, $param): void {
79        global $ID;
80
81        // check if action is show or translate
82        if ($event->data != 'show' and $event->data != 'translate') return;
83
84        $split_id = explode(':', $ID);
85        $lang_ns = array_shift($split_id);
86        // check if we are in a language namespace
87        if (array_key_exists($lang_ns, $this->langs)) {
88            // in language namespace --> autotrans_direct
89            $this->autotrans_direct($event);
90        } else {
91            // not in language namespace --> push translate
92            $this->push_translate($event);
93        }
94    }
95
96    private function autotrans_direct(Doku_Event $event): void {
97        global $ID;
98
99        // abort if action is translate and the translate button is disabled
100        if ($event->data == 'translate' and !$this->getConf('show_button')) return;
101
102        // do nothing on show action when mode is not direct
103        if ($event->data == 'show' and $this->get_mode() != 'direct') return;
104
105        // allow translation of existing pages is we are in the translate action
106        $allow_existing = ($event->data == 'translate');
107
108        // reset action to show
109        $event->data = 'show';
110
111        if (!$this->check_do_translation($allow_existing)) {
112            send_redirect(wl($ID));
113            return;
114        }
115
116        $org_page_text = $this->get_org_page_text();
117        $translated_text = $this->deepl_translate($org_page_text, $this->langs[$this->get_target_lang()]);
118
119        if ($translated_text === '') {
120            send_redirect(wl($ID));
121            return;
122        }
123
124        saveWikiText($ID, $translated_text, 'Automatic translation');
125
126        msg($this->getLang('msg_translation_success'), 1);
127
128        // reload the page after translation
129        send_redirect(wl($ID));
130    }
131
132    public function autotrans_editor(Doku_Event $event, $param): void {
133        if ($this->get_mode() != 'editor') return;
134
135        if (!$this->check_do_translation()) return;
136
137        $org_page_text = $this->get_org_page_text();
138
139        $event->data['tpl'] = $this->deepl_translate($org_page_text, $this->langs[$this->get_target_lang()]);
140    }
141
142    private function push_translate(Doku_Event $event): void {
143        global $ID;
144
145        // check if action is translate
146        if ($event->data != 'translate') return;
147
148        // check if button is enabled
149        if (!$this->getConf('show_button')) {
150            send_redirect(wl($ID));
151            return;
152        }
153
154        if (!$this->check_do_push_translate()) {
155            send_redirect(wl($ID));
156            return;
157        }
158
159        // push translate
160        $push_langs = $this->get_push_langs();
161        $org_page_text = rawWiki($ID);
162        foreach ($push_langs as $lang) {
163            // skip invalid languages
164            if (!array_key_exists($lang, $this->langs)) {
165                msg($this->getLang('msg_translation_fail_invalid_lang') . $lang, -1);
166                continue;
167            }
168
169            $lang_id = $lang . ':' . $ID;
170
171            // check permissions
172            $perm = auth_quickaclcheck($ID);
173            $exists = page_exists($lang_id);
174            if (($exists and $perm < AUTH_EDIT) or (!$exists and $perm < AUTH_CREATE)) {
175                msg($this->getLang('msg_translation_fail_no_permissions') . $lang_id, -1);
176                continue;
177            }
178
179            $translated_text = $this->deepl_translate($org_page_text, $this->langs[$lang]);
180            saveWikiText($lang_id, $translated_text, 'Automatic push translation');
181        }
182
183        msg($this->getLang('msg_translation_success'), 1);
184
185        // reload the page after translation to clear the action
186        send_redirect(wl($ID));
187    }
188
189    private function get_mode(): string {
190        global $ID;
191        if ($this->getConf('editor_regex')) {
192            if (preg_match('/' . $this->getConf('editor_regex') . '/', $ID) === 1) return 'editor';
193        }
194        if ($this->getConf('direct_regex')) {
195            if (preg_match('/' . $this->getConf('direct_regex') . '/', $ID) === 1) return 'direct';
196        }
197        return $this->getConf('mode');
198    }
199
200    private function get_target_lang(): string {
201        global $ID;
202        $split_id = explode(':', $ID);
203        return array_shift($split_id);
204    }
205
206    private function get_org_page_text(): string {
207        global $ID;
208
209        $split_id = explode(':', $ID);
210        array_shift($split_id);
211        $org_id = implode(':', $split_id);
212
213        return rawWiki($org_id);
214    }
215
216    private function check_do_translation($allow_existing = false): bool {
217        global $INFO;
218        global $ID;
219
220        // only translate if the current page does not exist
221        if ($INFO['exists'] and !$allow_existing) return false;
222
223        // permission check
224        $perm = auth_quickaclcheck($ID);
225        if (($INFO['exists'] and $perm < AUTH_EDIT) or (!$INFO['exists'] and $perm < AUTH_CREATE)) return false;
226
227        // skip blacklisted namespaces and pages
228        if ($this->getConf('blacklist_regex')) {
229            if (preg_match('/' . $this->getConf('blacklist_regex') . '/', $ID) === 1) return false;
230        }
231
232        $split_id = explode(':', $ID);
233        $lang_ns = array_shift($split_id);
234        // only translate if the current page is in a language namespace
235        if (!array_key_exists($lang_ns, $this->langs)) return false;
236
237        $org_id = implode(':', $split_id);
238        // check if the original page exists
239        if (!page_exists($org_id)) return false;
240
241        return true;
242    }
243
244    private function check_do_push_translate(): bool {
245        global $ID;
246
247        $push_langs = $this->get_push_langs();
248        // push_langs empty --> push_translate disabled --> abort
249        if (empty($push_langs)) return false;
250
251        // skip blacklisted namespaces and pages
252        if ($this->getConf('blacklist_regex')) {
253            // blacklist regex match --> abort
254            if (preg_match('/' . $this->getConf('blacklist_regex') . '/', $ID) === 1) return false;
255        }
256
257        return true;
258    }
259
260    private function deepl_translate($text, $target_lang): string {
261        if (!trim($this->getConf('api_key'))) return '';
262
263        $text = $this->insert_ignore_tags($text);
264
265        $data = [
266            'auth_key' => $this->getConf('api_key'),
267            'target_lang' => $target_lang,
268            'tag_handling' => 'xml',
269            'ignore_tags' => 'ignore,php',
270            'text' => $text
271        ];
272
273        if ($this->getConf('api') == 'free') {
274            $url = 'https://api-free.deepl.com/v2/translate';
275        } else {
276            $url = 'https://api.deepl.com/v2/translate';
277        }
278
279        $http = new DokuHTTPClient();
280        $raw_response = $http->post($url, $data);
281
282        if ($http->status >= 400) {
283            // add error messages
284            switch ($http->status) {
285                case 403:
286                    msg($this->getLang('msg_translation_fail_bad_key'), -1);
287                    break;
288                case 456:
289                    msg($this->getLang('msg_translation_fail_quota_exceeded'), -1);
290                    break;
291                default:
292                    msg($this->getLang('msg_translation_fail'), -1);
293                    break;
294            }
295
296            // if any error occurred return an empty string
297            return '';
298        }
299
300        $json_response = json_decode($raw_response, true);
301        $translated_text = $json_response['translations'][0]['text'];
302
303        $translated_text = $this->remove_ignore_tags($translated_text);
304
305        return $translated_text;
306    }
307
308    private function get_push_langs(): array {
309        $push_langs = trim($this->getConf('push_langs'));
310
311        if ($push_langs === '') return array();
312
313        return explode(' ', $push_langs);
314    }
315
316    private function insert_ignore_tags($text): string {
317        $text = str_replace('[[', '<ignore>[[', $text);
318        $text = str_replace('{{', '<ignore>{{', $text);
319        $text = str_replace(']]', ']]</ignore>', $text);
320        $text = str_replace('}}', '}}</ignore>', $text);
321        $text = str_replace("''", "<ignore>''</ignore>", $text);
322
323        $text = preg_replace('/(<file[\s\S]*?>[\s\S]*?<\/file>)/', '<ignore>${1}</ignore>', $text);
324        $text = preg_replace('/(<code[\s\S]*?>[\s\S]*?<\/code>)/', '<ignore>${1}</ignore>', $text);
325
326        $ignored_expressions = explode(':', $this->getConf('ignored_expressions'));
327
328        foreach ($ignored_expressions as $expression) {
329            $text = str_replace($expression, '<ignore>' . $expression . '</ignore>', $text);
330        }
331
332        return $text;
333    }
334
335    private function remove_ignore_tags($text): string {
336        $text = str_replace('<ignore>[[', '[[', $text);
337        $text = str_replace('<ignore>{{', '{{', $text);
338        $text = str_replace(']]</ignore>', ']]', $text);
339        $text = str_replace('}}</ignore>', '}}', $text);
340        $text = str_replace("<ignore>''</ignore>", "''", $text);
341
342        $text = preg_replace('/<ignore>(<file[\s\S]*?>[\s\S]*?<\/file>)<\/ignore>/', '${1}', $text);
343        $text = preg_replace('/<ignore>(<code[\s\S]*?>[\s\S]*?<\/code>)<\/ignore>/', '${1}', $text);
344
345        // restore < and > for example from arrows (-->) in wikitext
346        $text = str_replace('&gt;', '>', $text);
347        $text = str_replace('&lt;', '<', $text);
348
349        $ignored_expressions = explode(':', $this->getConf('ignored_expressions'));
350
351        foreach ($ignored_expressions as $expression) {
352            $text = str_replace('<ignore>' . $expression . '</ignore>', $expression, $text);
353        }
354
355        return $text;
356    }
357}
358
359