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('>', '>', $text); 995 $text = str_replace('<', '<', $text); 996 997 // restore & in wikitext 998 $text = str_replace('&', '&', $text); 999 1000 return $text; 1001 } 1002} 1003 1004