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 // use v1 of tag handling (not as strict XML parsing as default v2 - in 2026) 675 if ($this->getConf('tag_handling_v1')) { 676 $data['tag_handling_version'] = 'v1'; 677 } 678 679 // check if glossaries are enabled 680 if ($this->get_glossary_ns()) { 681 $src = substr($this->get_default_lang(), 0, 2); 682 $target = substr($target_lang, 0, 2); 683 $glossary_id = $this->get_glossary_id($src, $target); 684 if ($glossary_id) { 685 // use glossary if it is defined 686 $data['glossary_id'] = $glossary_id; 687 } 688 } 689 690 if ($this->getConf('api') == 'free') { 691 $url = 'https://api-free.deepl.com/v2/translate'; 692 } else { 693 $url = 'https://api.deepl.com/v2/translate'; 694 } 695 696 $http = new DokuHTTPClient(); 697 $http->keep_alive = false; 698 699 $http->headers = array('Authorization' => 'DeepL-Auth-Key ' . $this->getConf('api_key')); 700 701 $raw_response = $http->post($url, $data); 702 703 if ($http->status >= 400 || $http->status < 200) { 704 // add error messages 705 switch ($http->status) { 706 case 403: 707 throw new \Exception($this->getLang('msg_translation_fail_bad_key'), 403); 708 case 404: 709 throw new \Exception($this->getLang('msg_translation_fail_invalid_glossary'), 404); 710 case 456: 711 throw new \Exception($this->getLang('msg_translation_fail_quota_exceeded'), 456); 712 default: 713 if ($this->getConf('api_log_errors')) { 714 $logger = \dokuwiki\Logger::getInstance('deeplautotranslate'); 715 $logger->log("$http->status " . $http->resp_body, $data['text']); 716 } 717 throw new \Exception($this->getLang('msg_translation_fail'), $http->status ?: 500); 718 } 719 } 720 721 $json_response = json_decode($raw_response, true, JSON_THROW_ON_ERROR); 722 $translated_text = $json_response['translations'][0]['text']; 723 724 $translated_text = $this->remove_ignore_tags($translated_text); 725 726 return $translated_text; 727 } 728 729 private function get_push_langs(): array { 730 $push_langs = trim($this->getConf('push_langs')); 731 732 if ($push_langs === '') return array(); 733 734 return explode(' ', $push_langs); 735 } 736 737 /** 738 * Is the given ID a relative path? 739 * 740 * Always returns false if keep_relative is disabled. 741 * 742 * @param string $id 743 * @return bool 744 */ 745 private function is_relative_link($id): bool { 746 if (!$this->getConf('keep_relative')) return false; 747 if ($id === '') return false; 748 if (strpos($id, ':') === false) return true; 749 if ($id[0] === '.') return true; 750 if ($id[0] === '~') return true; 751 return false; 752 } 753 754 private function patch_links($text, $target_lang, $ns): string { 755 /* 756 * 1. Find links in [[ aa:bb ]] or [[ aa:bb | cc ]] 757 * 2. Extract aa:bb 758 * 3. Check if lang:aa:bb exists 759 * 3.1. --> Yes --> replace 760 * 3.2. --> No --> leave it as it is 761 */ 762 763 764 /* 765 * LINKS 766 */ 767 768 preg_match_all('/\[\[([\s\S]*?)(#[\s\S]*?)?((\|)([\s\S]*?))?]]/', $text, $matches, PREG_SET_ORDER); 769 770 foreach ($matches as $match) { 771 772 // external link --> skip 773 if (strpos($match[1], '://') !== false) continue; 774 775 // skip interwiki links 776 if (strpos($match[1], '>') !== false) continue; 777 778 // skip mail addresses 779 if (strpos($match[1], '@') !== false) continue; 780 781 // skip windows share links 782 if (strpos($match[1], '\\\\') !== false) continue; 783 784 $resolved_id = trim($match[1]); 785 if($this->is_relative_link($resolved_id)) continue; 786 787 resolve_pageid($ns, $resolved_id, $exists); 788 789 $resolved_id_full = $resolved_id; 790 791 // if the link already points to a target in a language namespace drop it and add the new language namespace 792 $split_id = explode(':', $resolved_id); 793 $lang_ns = array_shift($split_id); 794 if (array_key_exists($lang_ns, $this->langs)) { 795 $resolved_id = implode(':', $split_id); 796 } 797 798 $lang_id = $target_lang . ':' . $resolved_id; 799 800 if (!page_exists($lang_id)) { 801 // Page in target lang does not exist --> replace with absolute ID in case it was a relative ID 802 $new_link = '[[' . $resolved_id_full . $match[2] . $match[3] . ']]'; 803 } else { 804 // Page in target lang exists --> replace link 805 $new_link = '[[' . $lang_id . $match[2] . $match[3] . ']]'; 806 } 807 808 $text = str_replace($match[0], $new_link, $text); 809 810 } 811 812 /* 813 * MEDIA 814 */ 815 816 preg_match_all('/\{\{(([\s\S]*?)(\?[\s\S]*?)?)(\|([\s\S]*?))?}}/', $text, $matches, PREG_SET_ORDER); 817 818 foreach ($matches as $match) { 819 820 // external image --> skip 821 if (strpos($match[1], '://') !== false) continue; 822 823 // skip things like {{tag>...}} 824 if (strpos($match[1], '>') !== false) continue; 825 826 // keep alignment 827 $align_left = ""; 828 $align_right = ""; 829 830 // align left --> space in front of ID 831 if (substr($match[1], 0, 1) == " ") $align_left = " "; 832 // align right --> space behind id 833 if (substr($match[1], -1) == " ") $align_right = " "; 834 835 $resolved_id = trim($match[2]); 836 $params = trim($match[3]); 837 838 if($this->is_relative_link($resolved_id)) continue; 839 840 resolve_mediaid($ns, $resolved_id, $exists); 841 842 $resolved_id_full = $resolved_id; 843 844 // if the link already points to a target in a language namespace drop it and add the new language namespace 845 $split_id = explode(':', $resolved_id); 846 $lang_ns = array_shift($split_id); 847 if (array_key_exists($lang_ns, $this->langs)) { 848 $resolved_id = implode(':', $split_id); 849 } 850 851 $lang_id = $target_lang . ':' . $resolved_id; 852 853 $lang_id_fn = mediaFN($lang_id); 854 855 if (!file_exists($lang_id_fn)) { 856 // media in target lang does not exist --> replace with absolute ID in case it was a relative ID 857 $new_link = '{{' . $align_left . $resolved_id_full . $params . $align_right . $match[4] . '}}'; 858 } else { 859 // media in target lang exists --> replace it 860 $new_link = '{{' . $align_left . $lang_id . $params . $align_right . $match[4] . '}}'; 861 } 862 863 $text = str_replace($match[0], $new_link, $text); 864 865 } 866 867 return $text; 868 } 869 870 private function insert_ignore_tags($text): string { 871 // ignore every other xml-like tags (the tags themselves, not their content), otherwise deepl would break the formatting 872 $text = preg_replace('/<[\s\S]+?>/', '<ignore>${0}</ignore>', $text); 873 874 // prevent deepl from breaking headings 875 $text = preg_replace('/={1,6}/', '<ignore>${0}</ignore>', $text); 876 877 // prevent deepl from with some page lists 878 $text = str_replace("{{top}}", "<ignore>{{top}}</ignore>", $text); 879 $text = str_replace("{{rating}}", "<ignore>{{rating}}</ignore>", $text); 880 881 // prevent deepl from messing with nocache-instructions 882 $text = str_replace("~~NOCACHE~~", "<ignore>~~NOCACHE~~</ignore>", $text); 883 884 // fix for plugins like tag or template 885 $text = preg_replace('/\{\{[\s\w]+?>[\s\S]*?}}/', '<ignore>${0}</ignore>', $text); 886 887 // ignore links in wikitext (outside of dokuwiki-links) 888 $text = preg_replace('/\S+:\/\/\S+/', '<ignore>${0}</ignore>', $text); 889 890 // ignore link/media ids but translate the text (if existing) 891 $text = preg_replace('/\[\[([\s\S]*?)(#[\s\S]*?)?((\|)([\s\S]*?))?]]/', '<ignore>[[${1}${2}${4}</ignore>${5}<ignore>]]</ignore>', $text); 892 $text = preg_replace('/\{\{([\s\S]*?)(\?[\s\S]*?)?((\|)([\s\S]*?))?}}/', '<ignore>{{${1}${2}${4}</ignore>${5}<ignore>}}</ignore>', $text); 893 894 // prevent deepl from messing with tables 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 $text = str_replace("| ", "<ignore>| </ignore>", $text); 905 $text = str_replace(" |", "<ignore> |</ignore>", $text); 906 $text = str_replace("|", "<ignore>|</ignore>", $text); 907 908 // prevent deepl from doing strange things with dokuwiki syntax 909 // if a full line is formatted, we have to double-ignore for some reason 910 $text = str_replace("''", "<ignore><ignore>''</ignore></ignore>", $text); 911 $text = str_replace("//", "<ignore><ignore>//</ignore></ignore>", $text); 912 $text = str_replace("**", "<ignore><ignore>**</ignore></ignore>", $text); 913 $text = str_replace("__", "<ignore><ignore>__</ignore></ignore>", $text); 914 $text = str_replace("\\\\", "<ignore><ignore>\\\\</ignore></ignore>", $text); 915 916 // prevent deepl from messing with smileys 917 $smileys = array_keys(getSmileys()); 918 foreach ($smileys as $smiley) { 919 $text = str_replace($smiley, "<ignore>" . $smiley . "</ignore>", $text); 920 } 921 922 // ignore code tags 923 $text = preg_replace('/(<php[\s\S]*?>[\s\S]*?<\/php>)/', '<ignore>${1}</ignore>', $text); 924 $text = preg_replace('/(<file[\s\S]*?>[\s\S]*?<\/file>)/', '<ignore>${1}</ignore>', $text); 925 $text = preg_replace('/(<code[\s\S]*?>[\s\S]*?<\/code>)/', '<ignore>${1}</ignore>', $text); 926 927 // ignore the expressions from the ignore list 928 $ignored_expressions = explode(':', $this->getConf('ignored_expressions')); 929 930 foreach ($ignored_expressions as $expression) { 931 $text = str_replace($expression, '<ignore>' . $expression . '</ignore>', $text); 932 } 933 934 return $text; 935 } 936 937 private function remove_ignore_tags($text): string { 938 $ignored_expressions = explode(':', $this->getConf('ignored_expressions')); 939 940 foreach ($ignored_expressions as $expression) { 941 $text = str_replace('<ignore>' . $expression . '</ignore>', $expression, $text); 942 } 943 944 // prevent deepl from messing with nocache-instructions 945 $text = str_replace("<ignore>~~NOCACHE~~</ignore>", "~~NOCACHE~~", $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 // prevent deepl from breaking headings 991 $text = preg_replace('/<ignore>(={1,6})<\/ignore>/','${1}', $text); 992 993 // ignore every other xml-like tags (the tags themselves, not their content), otherwise deepl would break the formatting 994 $text = preg_replace('/<ignore>(<[\s\S]+?>)<\/ignore>/', '${1}', $text); 995 996 // restore < and > for example from arrows (-->) in wikitext 997 $text = str_replace('>', '>', $text); 998 $text = str_replace('<', '<', $text); 999 1000 // restore & in wikitext 1001 $text = str_replace('&', '&', $text); 1002 1003 return $text; 1004 } 1005} 1006 1007