13832d0abSNetali<?php 23832d0abSNetali/** 33832d0abSNetali * Deepl Autotranslate Plugin 43832d0abSNetali * 53832d0abSNetali * @author Jennifer Graul <me@netali.de> 63832d0abSNetali */ 73832d0abSNetali 83832d0abSNetaliif(!defined('DOKU_INC')) die(); 93832d0abSNetali 1081931e50SNetaliuse \dokuwiki\HTTP\DokuHTTPClient; 113c636ad3SNetaliuse \dokuwiki\plugin\deeplautotranslate\MenuItem; 1281931e50SNetali 133832d0abSNetaliclass action_plugin_deeplautotranslate extends DokuWiki_Action_Plugin { 143832d0abSNetali 153832d0abSNetali // manual mapping of ISO-languages to DeepL-languages to fix inconsistent naming 163832d0abSNetali private $langs = [ 173832d0abSNetali 'bg' => 'BG', 183832d0abSNetali 'cs' => 'CS', 193832d0abSNetali 'da' => 'DA', 203832d0abSNetali 'de' => 'DE', 213832d0abSNetali 'de-informal' => 'DE', 223832d0abSNetali 'el' => 'EL', 233832d0abSNetali 'en' => 'EN-GB', 243832d0abSNetali 'es' => 'ES', 253832d0abSNetali 'et' => 'ET', 263832d0abSNetali 'fi' => 'FI', 273832d0abSNetali 'fr' => 'FR', 283832d0abSNetali 'hu' => 'HU', 293832d0abSNetali 'hu-formal' => 'HU', 303832d0abSNetali 'it' => 'IT', 313832d0abSNetali 'ja' => 'JA', 323832d0abSNetali 'lt' => 'LT', 333832d0abSNetali 'lv' => 'LV', 343832d0abSNetali 'nl' => 'NL', 353832d0abSNetali 'pl' => 'PL', 363832d0abSNetali 'pt' => 'PT-PT', 373832d0abSNetali 'ro' => 'RO', 383832d0abSNetali 'ru' => 'RU', 393832d0abSNetali 'sk' => 'SK', 403832d0abSNetali 'sl' => 'SL', 413832d0abSNetali 'sv' => 'SV', 423832d0abSNetali 'zh' => 'ZH' 433832d0abSNetali ]; 443832d0abSNetali 453832d0abSNetali /** 463832d0abSNetali * Register its handlers with the DokuWiki's event controller 473832d0abSNetali */ 483832d0abSNetali public function register(Doku_Event_Handler $controller) { 49*153e4498SNetali $controller->register_hook('ACTION_ACT_PREPROCESS','BEFORE', $this, 'preprocess'); 503832d0abSNetali $controller->register_hook('COMMON_PAGETPL_LOAD','AFTER', $this, 'autotrans_editor'); 513c636ad3SNetali $controller->register_hook('MENU_ITEMS_ASSEMBLY', 'AFTER', $this, 'add_menu_button'); 523c636ad3SNetali } 533c636ad3SNetali 543c636ad3SNetali public function add_menu_button(Doku_Event $event) { 55*153e4498SNetali global $ID; 56*153e4498SNetali 573c636ad3SNetali if ($event->data['view'] != 'page') return; 583c636ad3SNetali 593c636ad3SNetali if (!$this->getConf('show_button')) return; 60*153e4498SNetali 61*153e4498SNetali $split_id = explode(':', $ID); 62*153e4498SNetali $lang_ns = array_shift($split_id); 63*153e4498SNetali // check if we are in a language namespace 64*153e4498SNetali if (array_key_exists($lang_ns, $this->langs)) { 65*153e4498SNetali // in language namespace --> check if we should translate 663c636ad3SNetali if (!$this->check_do_translation(true)) return; 67*153e4498SNetali } else { 68*153e4498SNetali // not in language namespace --> check if we should show the push translate button 69*153e4498SNetali if (!$this->check_do_push_translate()) return; 70*153e4498SNetali } 713c636ad3SNetali 723c636ad3SNetali array_splice($event->data['items'], -1, 0, [new MenuItem()]); 733832d0abSNetali } 743832d0abSNetali 75*153e4498SNetali public function preprocess(Doku_Event $event, $param): void { 763832d0abSNetali global $ID; 773c636ad3SNetali 783c636ad3SNetali // check if action is show or translate 793c636ad3SNetali if ($event->data != 'show' and $event->data != 'translate') return; 803c636ad3SNetali 81*153e4498SNetali $split_id = explode(':', $ID); 82*153e4498SNetali $lang_ns = array_shift($split_id); 83*153e4498SNetali // check if we are in a language namespace 84*153e4498SNetali if (array_key_exists($lang_ns, $this->langs)) { 85*153e4498SNetali // in language namespace --> autotrans_direct 86*153e4498SNetali $this->autotrans_direct($event); 87*153e4498SNetali } else { 88*153e4498SNetali // not in language namespace --> push translate 89*153e4498SNetali $this->push_translate($event); 90*153e4498SNetali } 91*153e4498SNetali } 92*153e4498SNetali 93*153e4498SNetali private function autotrans_direct(Doku_Event $event): void { 94*153e4498SNetali global $ID; 95*153e4498SNetali 963c636ad3SNetali // abort if action is translate and the translate button is disabled 973c636ad3SNetali if ($event->data == 'translate' and !$this->getConf('show_button')) return; 983c636ad3SNetali 993c636ad3SNetali // do nothing on show action when mode is not direct 1003c636ad3SNetali if ($event->data == 'show' and $this->get_mode() != 'direct') return; 1013c636ad3SNetali 1023c636ad3SNetali // allow translation of existing pages is we are in the translate action 1033c636ad3SNetali $allow_existing = ($event->data == 'translate'); 1043c636ad3SNetali 1053c636ad3SNetali // reset action to show 1063c636ad3SNetali $event->data = 'show'; 1073c636ad3SNetali 108*153e4498SNetali if (!$this->check_do_translation($allow_existing)) { 109*153e4498SNetali send_redirect(wl($ID)); 110*153e4498SNetali return; 111*153e4498SNetali } 1123832d0abSNetali 1133832d0abSNetali $org_page_text = $this->get_org_page_text(); 1143832d0abSNetali $translated_text = $this->deepl_translate($org_page_text, $this->langs[$this->get_target_lang()]); 1153832d0abSNetali 116*153e4498SNetali if ($translated_text === '') { 117*153e4498SNetali send_redirect(wl($ID)); 118*153e4498SNetali return; 119*153e4498SNetali } 1203832d0abSNetali 1213832d0abSNetali saveWikiText($ID, $translated_text, 'Automatic translation'); 1223832d0abSNetali 123*153e4498SNetali msg($this->getLang('msg_translation_success'), 1); 124*153e4498SNetali 1253c636ad3SNetali // reload the page after translation 1263c636ad3SNetali send_redirect(wl($ID)); 1273832d0abSNetali } 1283832d0abSNetali 129*153e4498SNetali public function autotrans_editor(Doku_Event $event, $param): void { 1303832d0abSNetali if ($this->get_mode() != 'editor') return; 1313832d0abSNetali 1323832d0abSNetali if (!$this->check_do_translation()) return; 1333832d0abSNetali 1343832d0abSNetali $org_page_text = $this->get_org_page_text(); 1353832d0abSNetali 1363832d0abSNetali $event->data['tpl'] = $this->deepl_translate($org_page_text, $this->langs[$this->get_target_lang()]); 1373832d0abSNetali } 1383832d0abSNetali 139*153e4498SNetali private function push_translate(Doku_Event $event): void { 140*153e4498SNetali global $ID; 141*153e4498SNetali 142*153e4498SNetali // check if action is translate 143*153e4498SNetali if ($event->data != 'translate') return; 144*153e4498SNetali 145*153e4498SNetali // check if button is enabled 146*153e4498SNetali if (!$this->getConf('show_button')) { 147*153e4498SNetali send_redirect(wl($ID)); 148*153e4498SNetali return; 149*153e4498SNetali } 150*153e4498SNetali 151*153e4498SNetali if (!$this->check_do_push_translate()) { 152*153e4498SNetali send_redirect(wl($ID)); 153*153e4498SNetali return; 154*153e4498SNetali } 155*153e4498SNetali 156*153e4498SNetali // push translate 157*153e4498SNetali $push_langs = $this->get_push_langs(); 158*153e4498SNetali $org_page_text = rawWiki($ID); 159*153e4498SNetali foreach ($push_langs as $lang) { 160*153e4498SNetali // skip invalid languages 161*153e4498SNetali if (!array_key_exists($lang, $this->langs)) { 162*153e4498SNetali msg($this->getLang('msg_translation_fail_invalid_lang') . $lang, -1); 163*153e4498SNetali continue; 164*153e4498SNetali } 165*153e4498SNetali 166*153e4498SNetali $lang_id = $lang . ':' . $ID; 167*153e4498SNetali 168*153e4498SNetali // check permissions 169*153e4498SNetali $perm = auth_quickaclcheck($ID); 170*153e4498SNetali $exists = page_exists($lang_id); 171*153e4498SNetali if (($exists and $perm < AUTH_EDIT) or (!$exists and $perm < AUTH_CREATE)) { 172*153e4498SNetali msg($this->getLang('msg_translation_fail_no_permissions') . $lang_id, -1); 173*153e4498SNetali continue; 174*153e4498SNetali } 175*153e4498SNetali 176*153e4498SNetali $translated_text = $this->deepl_translate($org_page_text, $this->langs[$lang]); 177*153e4498SNetali saveWikiText($lang_id, $translated_text, 'Automatic push translation'); 178*153e4498SNetali } 179*153e4498SNetali 180*153e4498SNetali msg($this->getLang('msg_translation_success'), 1); 181*153e4498SNetali 182*153e4498SNetali // reload the page after translation to clear the action 183*153e4498SNetali send_redirect(wl($ID)); 184*153e4498SNetali } 185*153e4498SNetali 1863832d0abSNetali private function get_mode(): string { 1873832d0abSNetali global $ID; 1883832d0abSNetali if ($this->getConf('editor_regex')) { 1893832d0abSNetali if (preg_match('/' . $this->getConf('editor_regex') . '/', $ID) === 1) return 'editor'; 1903832d0abSNetali } 1913832d0abSNetali if ($this->getConf('direct_regex')) { 1923832d0abSNetali if (preg_match('/' . $this->getConf('direct_regex') . '/', $ID) === 1) return 'direct'; 1933832d0abSNetali } 1943832d0abSNetali return $this->getConf('mode'); 1953832d0abSNetali } 1963832d0abSNetali 1973832d0abSNetali private function get_target_lang(): string { 1983832d0abSNetali global $ID; 1993832d0abSNetali $split_id = explode(':', $ID); 2003832d0abSNetali return array_shift($split_id); 2013832d0abSNetali } 2023832d0abSNetali 2033832d0abSNetali private function get_org_page_text(): string { 2043832d0abSNetali global $ID; 2053832d0abSNetali 2063832d0abSNetali $split_id = explode(':', $ID); 2073832d0abSNetali array_shift($split_id); 2083832d0abSNetali $org_id = implode(':', $split_id); 2093832d0abSNetali 2103832d0abSNetali return rawWiki($org_id); 2113832d0abSNetali } 2123832d0abSNetali 2133c636ad3SNetali private function check_do_translation($allow_existing = false): bool { 2143832d0abSNetali global $INFO; 2153832d0abSNetali global $ID; 2163832d0abSNetali 2173c636ad3SNetali // only translate if the current page does not exist 2183c636ad3SNetali if ($INFO['exists'] and !$allow_existing) return false; 2193c636ad3SNetali 2203c636ad3SNetali // permission check 2213c636ad3SNetali $perm = auth_quickaclcheck($ID); 2223c636ad3SNetali if (($INFO['exists'] and $perm < AUTH_EDIT) or (!$INFO['exists'] and $perm < AUTH_CREATE)) return false; 2233c636ad3SNetali 2243832d0abSNetali // skip blacklisted namespaces and pages 2253832d0abSNetali if ($this->getConf('blacklist_regex')) { 2263832d0abSNetali if (preg_match('/' . $this->getConf('blacklist_regex') . '/', $ID) === 1) return false; 2273832d0abSNetali } 2283832d0abSNetali 2293832d0abSNetali $split_id = explode(':', $ID); 2303832d0abSNetali $lang_ns = array_shift($split_id); 2313832d0abSNetali // only translate if the current page is in a language namespace 2323832d0abSNetali if (!array_key_exists($lang_ns, $this->langs)) return false; 2333832d0abSNetali 2343832d0abSNetali $org_id = implode(':', $split_id); 2353832d0abSNetali // check if the original page exists 2363832d0abSNetali if (!page_exists($org_id)) return false; 2373832d0abSNetali 2383832d0abSNetali return true; 2393832d0abSNetali } 2403832d0abSNetali 241*153e4498SNetali private function check_do_push_translate(): bool { 242*153e4498SNetali global $ID; 243*153e4498SNetali 244*153e4498SNetali $push_langs = $this->get_push_langs(); 245*153e4498SNetali // push_langs empty --> push_translate disabled --> abort 246*153e4498SNetali if (empty($push_langs)) return false; 247*153e4498SNetali 248*153e4498SNetali // skip blacklisted namespaces and pages 249*153e4498SNetali if ($this->getConf('blacklist_regex')) { 250*153e4498SNetali // blacklist regex match --> abort 251*153e4498SNetali if (preg_match('/' . $this->getConf('blacklist_regex') . '/', $ID) === 1) return false; 252*153e4498SNetali } 253*153e4498SNetali 254*153e4498SNetali return true; 255*153e4498SNetali } 256*153e4498SNetali 2573832d0abSNetali private function deepl_translate($text, $target_lang): string { 258*153e4498SNetali if (!trim($this->getConf('api_key'))) return ''; 2593832d0abSNetali 2603832d0abSNetali $text = $this->insert_ignore_tags($text); 2613832d0abSNetali 2623832d0abSNetali $data = [ 2633832d0abSNetali 'auth_key' => $this->getConf('api_key'), 2643832d0abSNetali 'target_lang' => $target_lang, 2653832d0abSNetali 'tag_handling' => 'xml', 266e4700ea0SNetali 'ignore_tags' => 'ignore,code,file,php', 2673832d0abSNetali 'text' => $text 2683832d0abSNetali ]; 2693832d0abSNetali 2703832d0abSNetali if ($this->getConf('api') == 'free') { 27181931e50SNetali $url = 'https://api-free.deepl.com/v2/translate'; 2723832d0abSNetali } else { 27381931e50SNetali $url = 'https://api.deepl.com/v2/translate'; 2743832d0abSNetali } 2753832d0abSNetali 27681931e50SNetali $http = new DokuHTTPClient(); 27781931e50SNetali $raw_response = $http->post($url, $data); 2783832d0abSNetali 2795f8ab21dSNetali if ($http->status >= 400) { 2805f8ab21dSNetali // add error messages 2815f8ab21dSNetali switch ($http->status) { 2825f8ab21dSNetali case 403: 2835f8ab21dSNetali msg($this->getLang('msg_translation_fail_bad_key'), -1); 2845f8ab21dSNetali break; 2855f8ab21dSNetali case 456: 2865f8ab21dSNetali msg($this->getLang('msg_translation_fail_quota_exceeded'), -1); 2875f8ab21dSNetali break; 2885f8ab21dSNetali default: 2895f8ab21dSNetali msg($this->getLang('msg_translation_fail'), -1); 2905f8ab21dSNetali break; 2915f8ab21dSNetali } 2925f8ab21dSNetali 2933832d0abSNetali // if any error occurred return an empty string 2945f8ab21dSNetali return ''; 2955f8ab21dSNetali } 2963832d0abSNetali 2973832d0abSNetali $json_response = json_decode($raw_response, true); 2983832d0abSNetali $translated_text = $json_response['translations'][0]['text']; 2993832d0abSNetali 3003832d0abSNetali $translated_text = $this->remove_ignore_tags($translated_text); 3013832d0abSNetali 3023832d0abSNetali return $translated_text; 3033832d0abSNetali } 3043832d0abSNetali 305*153e4498SNetali private function get_push_langs(): array { 306*153e4498SNetali $push_langs = trim($this->getConf('push_langs')); 307*153e4498SNetali 308*153e4498SNetali if ($push_langs === '') return array(); 309*153e4498SNetali 310*153e4498SNetali return explode(' ', $push_langs); 311*153e4498SNetali } 312*153e4498SNetali 3133832d0abSNetali private function insert_ignore_tags($text): string { 3143832d0abSNetali $text = str_replace('[[', '<ignore>[[', $text); 3153832d0abSNetali $text = str_replace('{{', '<ignore>{{', $text); 3163832d0abSNetali $text = str_replace(']]', ']]</ignore>', $text); 3173832d0abSNetali $text = str_replace('}}', '}}</ignore>', $text); 3185f8ab21dSNetali $text = str_replace("''", "<ignore>''</ignore>", $text); 3193832d0abSNetali 3203832d0abSNetali $ignored_expressions = explode(':', $this->getConf('ignored_expressions')); 3213832d0abSNetali 3223832d0abSNetali foreach ($ignored_expressions as $expression) { 3233832d0abSNetali $text = str_replace($expression, '<ignore>' . $expression . '</ignore>', $text); 3243832d0abSNetali } 3253832d0abSNetali 3263832d0abSNetali return $text; 3273832d0abSNetali } 3283832d0abSNetali 3293832d0abSNetali private function remove_ignore_tags($text): string { 3303832d0abSNetali $text = str_replace('<ignore>[[', '[[', $text); 3313832d0abSNetali $text = str_replace('<ignore>{{', '{{', $text); 3323832d0abSNetali $text = str_replace(']]</ignore>', ']]', $text); 3333832d0abSNetali $text = str_replace('}}</ignore>', '}}', $text); 3345f8ab21dSNetali $text = str_replace("<ignore>''</ignore>", "''", $text); 3355f8ab21dSNetali 3365f8ab21dSNetali // restore < and > for example from arrows (-->) in wikitext 3375f8ab21dSNetali $text = str_replace('>', '>', $text); 3385f8ab21dSNetali $text = str_replace('<', '<', $text); 3393832d0abSNetali 3403832d0abSNetali $ignored_expressions = explode(':', $this->getConf('ignored_expressions')); 3413832d0abSNetali 3423832d0abSNetali foreach ($ignored_expressions as $expression) { 3433832d0abSNetali $text = str_replace('<ignore>' . $expression . '</ignore>', $expression, $text); 3443832d0abSNetali } 3453832d0abSNetali 3463832d0abSNetali return $text; 3473832d0abSNetali } 3483832d0abSNetali} 3493832d0abSNetali 350