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