xref: /plugin/deeplautotranslate/action.php (revision 7c99a9b013bb6255ff4d1cc79571479708e2a064)
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 = array(
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, 'pagetpl_load');
51        $controller->register_hook('COMMON_WIKIPAGE_SAVE','AFTER', $this, 'update_glossary');
52        $controller->register_hook('MENU_ITEMS_ASSEMBLY', 'AFTER', $this, 'add_menu_button');
53    }
54
55    public function add_menu_button(Doku_Event $event): void {
56        global $ID;
57        global $ACT;
58
59        if ($ACT != 'show') return;
60
61        if ($event->data['view'] != 'page') return;
62
63        if (!$this->getConf('show_button')) return;
64
65        // no translations for the glossary namespace
66        if ($this->check_in_glossary_ns()) return;
67
68        $split_id = explode(':', $ID);
69        $lang_ns = array_shift($split_id);
70        // check if we are in a language namespace
71        if (array_key_exists($lang_ns, $this->langs)) {
72            if($this->getConf('default_lang_in_ns') and $lang_ns === $this->get_default_lang()) {
73                // if the default lang is in a namespace and we are in that namespace --> check for push translation
74                if (!$this->check_do_push_translate()) return;
75            } else {
76                // in language namespace --> check if we should translate
77                if (!$this->check_do_translation(true)) return;
78            }
79        } else {
80            // do not show the button if we are not in a language namespace and the default language is in a namespace
81            if($this->getConf('default_lang_in_ns')) return;
82            // not in language namespace and default language is not in a namespace --> check if we should show the push translate button
83            if (!$this->check_do_push_translate()) return;
84        }
85
86        array_splice($event->data['items'], -1, 0, [new MenuItem()]);
87    }
88
89    public function preprocess(Doku_Event $event, $param): void {
90        global $ID;
91
92        // check if action is show or translate
93        if ($event->data != 'show' and $event->data != 'translate') return;
94
95        // redirect to glossary ns start if glossary ns is called
96        if ($this->check_in_glossary_ns() and $event->data == 'show' and $ID == $this->get_glossary_ns()) {
97            send_redirect(wl($this->get_glossary_ns() . ':start'));
98        }
99
100        $split_id = explode(':', $ID);
101        $lang_ns = array_shift($split_id);
102        // check if we are in a language namespace
103        if (array_key_exists($lang_ns, $this->langs)) {
104            if($this->getConf('default_lang_in_ns') and $lang_ns === $this->get_default_lang()) {
105                // if the default lang is in a namespace and we are in that namespace --> push translate
106                $this->push_translate($event);
107            } else {
108                // in language namespace --> autotrans direct
109                $this->autotrans_direct($event);
110            }
111        } else {
112            // not in language namespace --> push translate
113            $this->push_translate($event);
114        }
115    }
116
117    public function pagetpl_load(Doku_Event $event, $param): void {
118        // handle glossary namespace init when we are in it
119        if ($this->check_in_glossary_ns()) {
120            $this->handle_glossary_init($event);
121            return;
122        }
123
124        $this->autotrans_editor($event);
125    }
126
127    public function update_glossary(Doku_Event $event, $param): void {
128        global $ID;
129        // this also checks if the glossary feature is enabled
130        if (!$this->check_in_glossary_ns()) return;
131
132        $glossary_ns = $this->get_glossary_ns();
133
134        // check if we are in a glossary definition
135        if(preg_match('/^' . $glossary_ns . ':(\w{2})_(\w{2})$/', $ID, $id_match)) {
136            $old_glossary_id = $this->get_glossary_id($id_match[1], $id_match[2]);
137            if ($event->data['changeType'] == DOKU_CHANGE_TYPE_DELETE) {
138                // page deleted --> delete glossary
139                if ($old_glossary_id) {
140                    $result = $this->delete_glossary($old_glossary_id);
141                    if ($result) {
142                        msg($this->getLang('msg_glossary_delete_success'), 1);
143                        $this->unset_glossary_id($id_match[1], $id_match[2]);
144                    }
145                }
146                return;
147            }
148
149            $entries = '';
150
151            // grep entries from definition table
152            preg_match_all('/[ \t]*\|(.*?)\|(.*?)\|/', $event->data['newContent'], $matches, PREG_SET_ORDER);
153            foreach ($matches as $match) {
154                $src = trim($match[1]);
155                $target = trim($match[2]);
156                if ($src == '' or $target == '') {
157                    msg($this->getLang('msg_glossary_empty_key'), -1);
158                    return;
159                }
160                $entries .=  $src . "\t" . $target . "\n";
161            }
162
163            if (empty($matches)) {
164                // no matches --> delete glossary
165                if ($old_glossary_id) {
166                    $result = $this->delete_glossary($old_glossary_id);
167                    if ($result) {
168                        msg($this->getLang('msg_glossary_delete_success'), 1);
169                        $this->unset_glossary_id($id_match[1], $id_match[2]);
170                    }
171                }
172                return;
173            }
174
175            $new_glossary_id = $this->create_glossary($id_match[1], $id_match[2], $entries);
176
177            if ($new_glossary_id) {
178                msg($this->getLang('msg_glossary_create_success'), 1);
179                $this->set_glossary_id($id_match[1], $id_match[2], $new_glossary_id);
180            } else {
181                return;
182            }
183
184            if ($old_glossary_id) $this->delete_glossary($old_glossary_id);
185        }
186    }
187
188    private function autotrans_direct(Doku_Event $event): void {
189        global $ID;
190
191        // abort if action is translate and the translate button is disabled
192        if ($event->data == 'translate' and !$this->getConf('show_button')) return;
193
194        // do nothing on show action when mode is not direct
195        if ($event->data == 'show' and $this->get_mode() != 'direct') return;
196
197        // allow translation of existing pages is we are in the translate action
198        $allow_existing = ($event->data == 'translate');
199
200        // reset action to show
201        $event->data = 'show';
202
203        if (!$this->check_do_translation($allow_existing)) {
204            return;
205        }
206
207        $org_page_info = $this->get_org_page_info();
208        $translated_text = $this->deepl_translate($org_page_info["text"], $this->get_target_lang(), $org_page_info["ns"]);
209
210        if ($translated_text === '') {
211            return;
212        }
213
214        saveWikiText($ID, $translated_text, 'Automatic translation');
215
216        msg($this->getLang('msg_translation_success'), 1);
217
218        // reload the page after translation
219        send_redirect(wl($ID));
220    }
221
222    private function autotrans_editor(Doku_Event $event): void {
223        if ($this->get_mode() != 'editor') return;
224
225        if (!$this->check_do_translation()) return;
226
227        $org_page_info = $this->get_org_page_info();
228
229        $event->data['tpl'] = $this->deepl_translate($org_page_info["text"], $this->get_target_lang(), $org_page_info["ns"]);
230    }
231
232    private function push_translate(Doku_Event $event): void {
233        global $ID;
234
235        // check if action is translate
236        if ($event->data != 'translate') return;
237
238        // check if button is enabled
239        if (!$this->getConf('show_button')) {
240            send_redirect(wl($ID));
241            return;
242        }
243
244        if (!$this->check_do_push_translate()) {
245            send_redirect(wl($ID));
246            return;
247        }
248
249        // push translate
250        $push_langs = $this->get_push_langs();
251        $org_page_text = rawWiki($ID);
252        foreach ($push_langs as $lang) {
253            // skip invalid languages
254            if (!array_key_exists($lang, $this->langs)) {
255                msg($this->getLang('msg_translation_fail_invalid_lang') . $lang, -1);
256                continue;
257            }
258
259            if ($this->getConf('default_lang_in_ns')) {
260                // if default lang is in ns: replace language namespace in ID
261                $split_id = explode(':', $ID);
262                array_shift($split_id);
263                $lang_id = implode(':', $split_id);
264                $lang_id = $lang . ':' . $lang_id;
265            } else {
266                // if default lang is not in ns: add language namespace to ID
267                $lang_id = $lang . ':' . $ID;
268            }
269
270            // check permissions
271            $perm = auth_quickaclcheck($lang_id);
272            $exists = page_exists($lang_id);
273            if (($exists and $perm < AUTH_EDIT) or (!$exists and $perm < AUTH_CREATE)) {
274                msg($this->getLang('msg_translation_fail_no_permissions') . $lang_id, -1);
275                continue;
276            }
277
278            $translated_text = $this->deepl_translate($org_page_text, $lang, getNS($ID));
279            saveWikiText($lang_id, $translated_text, 'Automatic push translation');
280        }
281
282        msg($this->getLang('msg_translation_success'), 1);
283
284        // reload the page after translation to clear the action
285        send_redirect(wl($ID));
286    }
287
288    private function handle_glossary_init(Doku_Event $event): void {
289        global $ID;
290
291        $glossary_ns = $this->get_glossary_ns();
292
293        // create glossary landing page
294        if ($ID == $glossary_ns . ':start') {
295            $landing_page_text = '====== ' . $this->getLang('glossary_landing_heading') . ' ======' . "\n";
296            $landing_page_text .= $this->getLang('glossary_landing_info_msg') . "\n";
297
298            $src_lang = substr($this->get_default_lang(), 0, 2);
299
300            $available_glossaries = $this->get_available_glossaries();
301            foreach ($available_glossaries as $glossary) {
302                if ($glossary['source_lang'] != $src_lang) continue;
303                // generate links to the available glossary pages
304                $landing_page_text .= '  * [[.:' . $glossary['source_lang'] . '_' . $glossary['target_lang'] . '|' . strtoupper($glossary['source_lang']) . ' -> ' . strtoupper($glossary['target_lang']) . ']]' . "\n";
305            }
306            $event->data['tpl'] = $landing_page_text;
307            return;
308        }
309
310        if (preg_match('/^' . $glossary_ns . ':(\w{2})_(\w{2})$/', $ID, $match)) {
311            // check if glossaries are supported for this language pair
312            if (!$this->check_glossary_supported($match[1], $match[2])) {
313                msg($this->getLang('msg_glossary_unsupported'), -1);
314                return;
315            }
316
317            $page_text = '====== ' . $this->getLang('glossary_definition_heading') . ': ' . strtoupper($match[1]) . ' -> ' . strtoupper($match[2]) . ' ======' . "\n";
318            $page_text .= $this->getLang('glossary_definition_help') . "\n\n";
319            $page_text .= '^ ' . strtoupper($match[1]) . ' ^ ' . strtoupper($match[2]) . ' ^' . "\n";
320
321            $event->data['tpl'] = $page_text;
322            return;
323        }
324    }
325
326    private function get_glossary_ns(): string {
327        return trim(strtolower($this->getConf('glossary_ns')));
328    }
329
330    private function get_mode(): string {
331        global $ID;
332        if ($this->getConf('editor_regex')) {
333            if (preg_match('/' . $this->getConf('editor_regex') . '/', $ID) === 1) return 'editor';
334        }
335        if ($this->getConf('direct_regex')) {
336            if (preg_match('/' . $this->getConf('direct_regex') . '/', $ID) === 1) return 'direct';
337        }
338        return $this->getConf('mode');
339    }
340
341    private function get_target_lang(): string {
342        global $ID;
343        $split_id = explode(':', $ID);
344        return array_shift($split_id);
345    }
346
347    private function get_default_lang(): string {
348        global $conf;
349
350        if (empty($conf['lang_before_translation'])) {
351            $default_lang = $conf['lang'];
352        } else {
353            $default_lang = $conf['lang_before_translation'];
354        }
355
356        return $default_lang;
357    }
358
359    private function get_org_page_info(): array {
360        global $ID;
361
362        $split_id = explode(':', $ID);
363        array_shift($split_id);
364        $org_id = implode(':', $split_id);
365
366        // if default lang is in ns: add default ns in front of org id
367        if ($this->getConf('default_lang_in_ns')) {
368            $org_id = $this->get_default_lang() . ':' . $org_id;
369        }
370
371        return array("ns" => getNS($org_id), "text" => rawWiki($org_id));
372    }
373
374    private function get_available_glossaries(): array {
375        if (!trim($this->getConf('api_key'))) {
376            msg($this->getLang('msg_bad_key'), -1);
377            return array();
378        }
379
380        if ($this->getConf('api') == 'free') {
381            $url = 'https://api-free.deepl.com/v2/glossary-language-pairs';
382        } else {
383            $url = 'https://api.deepl.com/v2/glossary-language-pairs';
384        }
385
386        $http = new DokuHTTPClient();
387
388        $http->headers = array('Authorization' => 'DeepL-Auth-Key ' . $this->getConf('api_key'));
389
390        $raw_response = $http->get($url);
391
392        if ($http->status >= 400) {
393            // add error messages
394            switch ($http->status) {
395                case 403:
396                    msg($this->getLang('msg_bad_key'), -1);
397                    break;
398                default:
399                    msg($this->getLang('msg_glossary_fetch_fail'), -1);
400                    break;
401            }
402
403            // if any error occurred return an empty array
404            return array();
405        }
406
407        $json_response = json_decode($raw_response, true);
408
409        return $json_response['supported_languages'];
410    }
411
412    private function get_glossary_id($src, $target): string {
413        if (!file_exists(DOKU_CONF . 'deepl-glossaries.json')) return '';
414
415        $key = $src . "_" . $target;
416
417        $raw_json = file_get_contents(DOKU_CONF . 'deepl-glossaries.json');
418        $content = json_decode($raw_json, true);
419
420        if (array_key_exists($key, $content)) {
421            return $content[$key];
422        } else {
423            return '';
424        }
425    }
426
427    private function set_glossary_id($src, $target, $glossary_id): void {
428        if (file_exists(DOKU_CONF . 'deepl-glossaries.json')) {
429            $raw_json = file_get_contents(DOKU_CONF . 'deepl-glossaries.json');
430            $content = json_decode($raw_json, true);
431        } else {
432            $content = array();
433        }
434
435        $key = $src . "_" . $target;
436
437        $content[$key] = $glossary_id;
438
439        $raw_json = json_encode($content);
440        file_put_contents(DOKU_CONF . 'deepl-glossaries.json', $raw_json);
441    }
442
443    private function unset_glossary_id($src, $target): void {
444        if (file_exists(DOKU_CONF . 'deepl-glossaries.json')) {
445            $raw_json = file_get_contents(DOKU_CONF . 'deepl-glossaries.json');
446            $content = json_decode($raw_json, true);
447        } else {
448            return;
449        }
450
451        $key = $src . "_" . $target;
452
453        unset($content[$key]);
454
455        $raw_json = json_encode($content);
456        file_put_contents(DOKU_CONF . 'deepl-glossaries.json', $raw_json);
457    }
458
459    private function check_in_glossary_ns(): bool {
460        global $ID;
461
462        $glossary_ns = $this->get_glossary_ns();
463
464        // check if the glossary namespace is defined
465        if (!$glossary_ns) return false;
466
467        // check if we are in the glossary namespace
468        if (substr($ID, 0, strlen($glossary_ns)) == $glossary_ns) {
469            return true;
470        } else {
471            return false;
472        }
473    }
474
475    private function check_glossary_supported($src, $target): bool {
476        if(strlen($src) != 2 or strlen($target) != 2) return false;
477        $available_glossaries = $this->get_available_glossaries();
478        foreach ($available_glossaries as $glossary) {
479            if ($src == $glossary['source_lang'] and $target == $glossary['target_lang']) return true;
480        }
481        return false;
482    }
483
484    private function check_do_translation($allow_existing = false): bool {
485        global $INFO;
486        global $ID;
487
488        // only translate if the current page does not exist
489        if ($INFO['exists'] and !$allow_existing) return false;
490
491        // permission check
492        $perm = auth_quickaclcheck($ID);
493        if (($INFO['exists'] and $perm < AUTH_EDIT) or (!$INFO['exists'] and $perm < AUTH_CREATE)) return false;
494
495        // skip blacklisted namespaces and pages
496        if ($this->getConf('blacklist_regex')) {
497            if (preg_match('/' . $this->getConf('blacklist_regex') . '/', $ID) === 1) return false;
498        }
499
500        $split_id = explode(':', $ID);
501        $lang_ns = array_shift($split_id);
502        // only translate if the current page is in a language namespace
503        if (!array_key_exists($lang_ns, $this->langs)) return false;
504
505        $org_id = implode(':', $split_id);
506
507        // if default lang is in ns: add default ns in front of org id
508        if ($this->getConf('default_lang_in_ns')) {
509            $org_id = $this->get_default_lang() . ':' . $org_id;
510        }
511
512        // no translations for the glossary namespace
513        $glossary_ns = $this->get_glossary_ns();
514        if ($glossary_ns and substr($org_id, 0, strlen($glossary_ns)) == $glossary_ns) return false;
515
516        // check if the original page exists
517        if (!page_exists($org_id)) return false;
518
519        return true;
520    }
521
522    private function check_do_push_translate(): bool {
523        global $ID;
524        global $INFO;
525
526        if (!$INFO['exists']) return false;
527
528        // only allow push translation if the user can edit this page
529        $perm = auth_quickaclcheck($ID);
530        if ($perm < AUTH_EDIT) return false;
531
532        // if default language is in namespace: only allow push translation from that namespace
533        if($this->getConf('default_lang_in_ns')) {
534            $split_id = explode(':', $ID);
535            $lang_ns = array_shift($split_id);
536
537            if ($lang_ns !== $this->get_default_lang()) return false;
538        }
539
540        // no translations for the glossary namespace
541        if ($this->check_in_glossary_ns()) return false;
542
543        $push_langs = $this->get_push_langs();
544        // push_langs empty --> push_translate disabled --> abort
545        if (empty($push_langs)) return false;
546
547        // skip blacklisted namespaces and pages
548        if ($this->getConf('blacklist_regex')) {
549            // blacklist regex match --> abort
550            if (preg_match('/' . $this->getConf('blacklist_regex') . '/', $ID) === 1) return false;
551        }
552
553        return true;
554    }
555
556    private function create_glossary($src, $target, $entries): string {
557        if (!trim($this->getConf('api_key'))) {
558            msg($this->getLang('msg_bad_key'), -1);
559            return '';
560        }
561
562        if ($this->getConf('api') == 'free') {
563            $url = 'https://api-free.deepl.com/v2/glossaries';
564        } else {
565            $url = 'https://api.deepl.com/v2/glossaries';
566        }
567
568        $data = array(
569            'name' => 'DokuWiki-Autotranslate-' . $src . '_' . $target,
570            'source_lang' => $src,
571            'target_lang' => $target,
572            'entries' => $entries,
573            'entries_format' => 'tsv'
574        );
575
576        $http = new DokuHTTPClient();
577
578        $http->headers = array('Authorization' => 'DeepL-Auth-Key ' . $this->getConf('api_key'));
579
580        $raw_response = $http->post($url, $data);
581
582        if ($http->status >= 400) {
583            // add error messages
584            switch ($http->status) {
585                case 403:
586                    msg($this->getLang('msg_bad_key'), -1);
587                    break;
588                case 400:
589                    msg($this->getLang('msg_glossary_content_invalid'), -1);
590                    break;
591                default:
592                    msg($this->getLang('msg_glossary_create_fail'), -1);
593                    break;
594            }
595
596            // if any error occurred return an empty string
597            return '';
598        }
599
600        $json_response = json_decode($raw_response, true);
601
602        return $json_response['glossary_id'];
603    }
604
605    private function delete_glossary($glossary_id): bool {
606        if (!trim($this->getConf('api_key'))) {
607            msg($this->getLang('msg_bad_key'), -1);
608            return false;
609        }
610
611        if ($this->getConf('api') == 'free') {
612            $url = 'https://api-free.deepl.com/v2/glossaries';
613        } else {
614            $url = 'https://api.deepl.com/v2/glossaries';
615        }
616
617        $url .= '/' . $glossary_id;
618
619        $http = new DokuHTTPClient();
620
621        $http->headers = array('Authorization' => 'DeepL-Auth-Key ' . $this->getConf('api_key'));
622
623        $http->sendRequest($url, '', 'DELETE');
624
625        if ($http->status >= 400) {
626            // add error messages
627            switch ($http->status) {
628                case 403:
629                    msg($this->getLang('msg_bad_key'), -1);
630                    break;
631                default:
632                    msg($this->getLang('msg_glossary_delete_fail'), -1);
633                    break;
634            }
635
636            // if any error occurred return false
637            return false;
638        }
639
640        return true;
641    }
642
643    private function deepl_translate($text, $target_lang, $org_ns): string {
644        if (!trim($this->getConf('api_key'))) {
645            msg($this->getLang('msg_translation_fail_bad_key'), -1);
646            return '';
647        }
648
649        $text = $this->patch_links($text, $target_lang, $org_ns);
650
651        $text = $this->insert_ignore_tags($text);
652
653        $data = array(
654            'source_lang' => strtoupper(substr($this->get_default_lang(), 0, 2)), // cut of things like "-informal"
655            'target_lang' => $this->langs[$target_lang],
656            'tag_handling' => 'xml',
657            'ignore_tags' => 'ignore',
658            'text' => $text
659        );
660
661        // check if glossaries are enabled
662        if ($this->get_glossary_ns()) {
663            $src = substr($this->get_default_lang(), 0, 2);
664            $target = substr($target_lang, 0, 2);
665            $glossary_id = $this->get_glossary_id($src, $target);
666            if ($glossary_id) {
667                // use glossary if it is defined
668                $data['glossary_id'] = $glossary_id;
669            }
670        }
671
672        if ($this->getConf('api') == 'free') {
673            $url = 'https://api-free.deepl.com/v2/translate';
674        } else {
675            $url = 'https://api.deepl.com/v2/translate';
676        }
677
678        $http = new DokuHTTPClient();
679
680        $http->headers = array('Authorization' => 'DeepL-Auth-Key ' . $this->getConf('api_key'));
681
682        $raw_response = $http->post($url, $data);
683
684        if ($http->status >= 400) {
685            // add error messages
686            switch ($http->status) {
687                case 403:
688                    msg($this->getLang('msg_translation_fail_bad_key'), -1);
689                    break;
690                case 404:
691                    msg($this->getLang('msg_translation_fail_invalid_glossary'), -1);
692                    break;
693                case 456:
694                    msg($this->getLang('msg_translation_fail_quota_exceeded'), -1);
695                    break;
696                default:
697                    msg($this->getLang('msg_translation_fail'), -1);
698                    break;
699            }
700
701            // if any error occurred return an empty string
702            return '';
703        }
704
705        $json_response = json_decode($raw_response, true);
706        $translated_text = $json_response['translations'][0]['text'];
707
708        $translated_text = $this->remove_ignore_tags($translated_text);
709
710        return $translated_text;
711    }
712
713    private function get_push_langs(): array {
714        $push_langs = trim($this->getConf('push_langs'));
715
716        if ($push_langs === '') return array();
717
718        return explode(' ', $push_langs);
719    }
720
721    private function patch_links($text, $target_lang, $ns): string {
722        /*
723         * 1. Find links in [[ aa:bb ]] or [[ aa:bb | cc ]]
724         * 2. Extract aa:bb
725         * 3. Check if lang:aa:bb exists
726         * 3.1. --> Yes --> replace
727         * 3.2. --> No --> leave it as it is
728         */
729
730
731        /*
732         * LINKS
733         */
734
735        preg_match_all('/\[\[([\s\S]*?)(#[\s\S]*?)?((\|)([\s\S]*?))?]]/', $text, $matches, PREG_SET_ORDER);
736
737        foreach ($matches as $match) {
738
739            // external link --> skip
740            if (strpos($match[1], '://') !== false) continue;
741
742            // skip interwiki links
743            if (strpos($match[1], '>') !== false) continue;
744
745            // skip windows share links
746            if (strpos($match[1], '\\\\') !== false) continue;
747
748            $resolved_id = trim($match[1]);
749
750            resolve_pageid($ns, $resolved_id, $exists);
751
752            $resolved_id_full = $resolved_id;
753
754            // if the link already points to a target in a language namespace drop it and add the new language namespace
755            $split_id = explode(':', $resolved_id);
756            $lang_ns = array_shift($split_id);
757            if (array_key_exists($lang_ns, $this->langs)) {
758                $resolved_id = implode(':', $split_id);
759            }
760
761            $lang_id = $target_lang . ':' . $resolved_id;
762
763            if (!page_exists($lang_id)) {
764                // Page in target lang does not exist --> replace with absolute ID in case it was a relative ID
765                $new_link = '[[' . $resolved_id_full . $match[2] . $match[3] . ']]';
766            } else {
767                // Page in target lang exists --> replace link
768                $new_link = '[[' . $lang_id . $match[2] . $match[3] . ']]';
769            }
770
771            $text = str_replace($match[0], $new_link, $text);
772
773        }
774
775        /*
776         * MEDIA
777         */
778
779        preg_match_all('/\{\{(([\s\S]*?)(\?[\s\S]*?)?)(\|([\s\S]*?))?}}/', $text, $matches, PREG_SET_ORDER);
780
781        foreach ($matches as $match) {
782
783            // external image --> skip
784            if (strpos($match[1], '://') !== false) continue;
785
786            // skip things like {{tag>...}}
787            if (strpos($match[1], '>') !== false) continue;
788
789            // keep alignment
790            $align_left = "";
791            $align_right = "";
792
793            // align left --> space in front of ID
794            if (substr($match[1], 0, 1) == " ") $align_left = " ";
795            // align right --> space behind id
796            if (substr($match[1], -1) == " ") $align_right = " ";
797
798            $resolved_id = trim($match[2]);
799            $params = trim($match[3]);
800
801            resolve_mediaid($ns, $resolved_id, $exists);
802
803            $resolved_id_full = $resolved_id;
804
805            // if the link already points to a target in a language namespace drop it and add the new language namespace
806            $split_id = explode(':', $resolved_id);
807            $lang_ns = array_shift($split_id);
808            if (array_key_exists($lang_ns, $this->langs)) {
809                $resolved_id = implode(':', $split_id);
810            }
811
812            $lang_id = $target_lang . ':' . $resolved_id;
813
814            $lang_id_fn = mediaFN($lang_id);
815
816            if (!file_exists($lang_id_fn)) {
817                // media in target lang does not exist --> replace with absolute ID in case it was a relative ID
818                $new_link = '{{' . $align_left . $resolved_id_full . $params . $align_right . $match[4] . '}}';
819            } else {
820                // media in target lang exists --> replace it
821                $new_link = '{{' . $align_left . $lang_id . $params . $align_right . $match[4] . '}}';
822            }
823
824            $text = str_replace($match[0], $new_link, $text);
825
826        }
827
828        return $text;
829    }
830
831    private function insert_ignore_tags($text): string {
832        // ignore every other xml-like tags (the tags themselves, not their content), otherwise deepl would break the formatting
833        $text = preg_replace('/<[\s\S]+?>/', '<ignore>${0}</ignore>', $text);
834
835        // prevent deepl from breaking headings
836        $text = preg_replace('/={1,6}/', '<ignore>${0}</ignore>', $text);
837
838        // prevent deepl from messing with nocache-instructions
839        $text = str_replace("~~NOCACHE~~", "<ignore>~~NOCACHE~~</ignore>", $text);
840
841        // fix for plugins like tag or template
842        $text = preg_replace('/\{\{[\s\w]+?>[\s\S]*?}}/', '<ignore>${0}</ignore>', $text);
843
844        // ignore links in wikitext (outside of dokuwiki-links)
845        $text = preg_replace('/\S+:\/\/\S+/', '<ignore>${0}</ignore>', $text);
846
847        // ignore link/media ids but translate the text (if existing)
848        $text = preg_replace('/\[\[([\s\S]*?)(#[\s\S]*?)?((\|)([\s\S]*?))?]]/', '<ignore>[[${1}${2}${4}</ignore>${5}<ignore>]]</ignore>', $text);
849        $text = preg_replace('/\{\{([\s\S]*?)(\?[\s\S]*?)?((\|)([\s\S]*?))?}}/', '<ignore>{{${1}${2}${4}</ignore>${5}<ignore>}}</ignore>', $text);
850
851        // prevent deepl from messing with tables
852        $text = str_replace("  ^  ", "<ignore>  ^  </ignore>", $text);
853        $text = str_replace("  ^ ", "<ignore>  ^ </ignore>", $text);
854        $text = str_replace(" ^  ", "<ignore> ^  </ignore>", $text);
855        $text = str_replace("^  ", "<ignore>^  </ignore>", $text);
856        $text = str_replace("  ^", "<ignore>  ^</ignore>", $text);
857        $text = str_replace("^", "<ignore>^</ignore>", $text);
858        $text = str_replace("  |  ", "<ignore>  |  </ignore>", $text);
859        $text = str_replace("  | ", "<ignore>  | </ignore>", $text);
860        $text = str_replace(" |  ", "<ignore> |  </ignore>", $text);
861        $text = str_replace("|  ", "<ignore>|  </ignore>", $text);
862        $text = str_replace("  |", "<ignore>  |</ignore>", $text);
863        $text = str_replace("|", "<ignore>|</ignore>", $text);
864
865        // prevent deepl from doing strange things with dokuwiki syntax
866        // if a full line is formatted, we have to double-ignore for some reason
867        $text = str_replace("''", "<ignore><ignore>''</ignore></ignore>", $text);
868        $text = str_replace("//", "<ignore><ignore>//</ignore></ignore>", $text);
869        $text = str_replace("**", "<ignore><ignore>**</ignore></ignore>", $text);
870        $text = str_replace("__", "<ignore><ignore>__</ignore></ignore>", $text);
871        $text = str_replace("\\\\", "<ignore><ignore>\\\\</ignore></ignore>", $text);
872
873        // prevent deepl from messing with smileys
874        $smileys = array_keys(getSmileys());
875        foreach ($smileys as $smiley) {
876            $text = str_replace($smiley, "<ignore>" . $smiley . "</ignore>", $text);
877        }
878
879        // ignore code tags
880        $text = preg_replace('/(<php[\s\S]*?>[\s\S]*?<\/php>)/', '<ignore>${1}</ignore>', $text);
881        $text = preg_replace('/(<file[\s\S]*?>[\s\S]*?<\/file>)/', '<ignore>${1}</ignore>', $text);
882        $text = preg_replace('/(<code[\s\S]*?>[\s\S]*?<\/code>)/', '<ignore>${1}</ignore>', $text);
883
884        // ignore the expressions from the ignore list
885        $ignored_expressions = explode(':', $this->getConf('ignored_expressions'));
886
887        foreach ($ignored_expressions as $expression) {
888            $text = str_replace($expression, '<ignore>' . $expression . '</ignore>', $text);
889        }
890
891        return $text;
892    }
893
894    private function remove_ignore_tags($text): string {
895        $ignored_expressions = explode(':', $this->getConf('ignored_expressions'));
896
897        foreach ($ignored_expressions as $expression) {
898            $text = str_replace('<ignore>' . $expression . '</ignore>', $expression, $text);
899        }
900
901        // prevent deepl from messing with nocache-instructions
902        $text = str_replace("<ignore>~~NOCACHE~~</ignore>", "~~NOCACHE~~", $text);
903
904        // prevent deepl from messing with tables
905        $text = str_replace("<ignore>^</ignore>", "^", $text);
906        $text = str_replace("<ignore>^  </ignore>", "^  ", $text);
907        $text = str_replace("<ignore>  ^</ignore>", "  ^", $text);
908        $text = str_replace("<ignore> ^  </ignore>", " ^  ", $text);
909        $text = str_replace("<ignore>  ^ </ignore>", "  ^ ", $text);
910        $text = str_replace("<ignore>  ^  </ignore>", "  ^  ", $text);
911        $text = str_replace("<ignore>|</ignore>", "|", $text);
912        $text = str_replace("<ignore>|  </ignore>", "|  ", $text);
913        $text = str_replace("<ignore>  |</ignore>", "  |", $text);
914        $text = str_replace("<ignore> |  </ignore>", " |  ", $text);
915        $text = str_replace("<ignore>  | </ignore>", "  | ", $text);
916        $text = str_replace("<ignore>  |  </ignore>", "  |  ", $text);
917
918        $text = str_replace("<ignore><ignore>''</ignore></ignore>", "''", $text);
919        $text = str_replace("<ignore><ignore>//</ignore></ignore>", "//", $text);
920        $text = str_replace("<ignore><ignore>**</ignore></ignore>", "**", $text);
921        $text = str_replace("<ignore><ignore>__</ignore></ignore>", "__", $text);
922        $text = str_replace("<ignore><ignore>\\\\</ignore></ignore>", "\\\\", $text);
923
924        // ignore links in wikitext (outside of dokuwiki-links)
925        $text = preg_replace('/<ignore>(\S+:\/\/\S+)<\/ignore>/', '${1}', $text);
926
927        $text = preg_replace('/<ignore>\[\[([\s\S]*?)(\|)?(<\/ignore>)([\s\S]*?)?<ignore>]]<\/ignore>/', '[[${1}${2}${4}]]', $text);
928        $text = preg_replace('/<ignore>\{\{([\s\S]*?)(\|)?(<\/ignore>)([\s\S]*?)?<ignore>}}<\/ignore>/', '{{${1}${2}${4}}}', $text);
929
930        // prevent deepl from messing with smileys
931        $smileys = array_keys(getSmileys());
932        foreach ($smileys as $smiley) {
933            $text = str_replace("<ignore>" . $smiley . "</ignore>", $smiley, $text);
934        }
935
936        $text = preg_replace('/<ignore>(<php[\s\S]*?>[\s\S]*?<\/php>)<\/ignore>/', '${1}', $text);
937        $text = preg_replace('/<ignore>(<file[\s\S]*?>[\s\S]*?<\/file>)<\/ignore>/', '${1}', $text);
938        $text = preg_replace('/<ignore>(<code[\s\S]*?>[\s\S]*?<\/code>)<\/ignore>/', '${1}', $text);
939
940        // fix for plugins like tag or template
941        $text = preg_replace('/<ignore>(\{\{[\s\w]+?>[\s\S]*?}})<\/ignore>/', '${1}', $text);
942
943        // prevent deepl from breaking headings
944        $text = preg_replace('/<ignore>(={1,6})<\/ignore>/','${1}', $text);
945
946        // ignore every other xml-like tags (the tags themselves, not their content), otherwise deepl would break the formatting
947        $text = preg_replace('/<ignore>(<[\s\S]+?>)<\/ignore>/', '${1}', $text);
948
949        // restore < and > for example from arrows (-->) in wikitext
950        $text = str_replace('&gt;', '>', $text);
951        $text = str_replace('&lt;', '<', $text);
952
953        // restore & in wikitext
954        $text = str_replace('&amp;', '&', $text);
955
956        return $text;
957    }
958}
959
960