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