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 = [ 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 'zh' => 'ZH' 43 ]; 44 45 /** 46 * Register its handlers with the DokuWiki's event controller 47 */ 48 public function register(Doku_Event_Handler $controller) { 49 $controller->register_hook('ACTION_ACT_PREPROCESS','BEFORE', $this, 'preprocess'); 50 $controller->register_hook('COMMON_PAGETPL_LOAD','AFTER', $this, 'autotrans_editor'); 51 $controller->register_hook('MENU_ITEMS_ASSEMBLY', 'AFTER', $this, 'add_menu_button'); 52 } 53 54 public function add_menu_button(Doku_Event $event) { 55 global $ID; 56 global $ACT; 57 58 if ($ACT != 'show') return; 59 60 if ($event->data['view'] != 'page') return; 61 62 if (!$this->getConf('show_button')) return; 63 64 $split_id = explode(':', $ID); 65 $lang_ns = array_shift($split_id); 66 // check if we are in a language namespace 67 if (array_key_exists($lang_ns, $this->langs)) { 68 // in language namespace --> check if we should translate 69 if (!$this->check_do_translation(true)) return; 70 } else { 71 // not in language namespace --> check if we should show the push translate button 72 if (!$this->check_do_push_translate()) return; 73 } 74 75 array_splice($event->data['items'], -1, 0, [new MenuItem()]); 76 } 77 78 public function preprocess(Doku_Event $event, $param): void { 79 global $ID; 80 81 // check if action is show or translate 82 if ($event->data != 'show' and $event->data != 'translate') return; 83 84 $split_id = explode(':', $ID); 85 $lang_ns = array_shift($split_id); 86 // check if we are in a language namespace 87 if (array_key_exists($lang_ns, $this->langs)) { 88 // in language namespace --> autotrans_direct 89 $this->autotrans_direct($event); 90 } else { 91 // not in language namespace --> push translate 92 $this->push_translate($event); 93 } 94 } 95 96 private function autotrans_direct(Doku_Event $event): void { 97 global $ID; 98 99 // abort if action is translate and the translate button is disabled 100 if ($event->data == 'translate' and !$this->getConf('show_button')) return; 101 102 // do nothing on show action when mode is not direct 103 if ($event->data == 'show' and $this->get_mode() != 'direct') return; 104 105 // allow translation of existing pages is we are in the translate action 106 $allow_existing = ($event->data == 'translate'); 107 108 // reset action to show 109 $event->data = 'show'; 110 111 if (!$this->check_do_translation($allow_existing)) { 112 send_redirect(wl($ID)); 113 return; 114 } 115 116 $org_page_text = $this->get_org_page_text(); 117 $translated_text = $this->deepl_translate($org_page_text, $this->langs[$this->get_target_lang()]); 118 119 if ($translated_text === '') { 120 send_redirect(wl($ID)); 121 return; 122 } 123 124 saveWikiText($ID, $translated_text, 'Automatic translation'); 125 126 msg($this->getLang('msg_translation_success'), 1); 127 128 // reload the page after translation 129 send_redirect(wl($ID)); 130 } 131 132 public function autotrans_editor(Doku_Event $event, $param): void { 133 if ($this->get_mode() != 'editor') return; 134 135 if (!$this->check_do_translation()) return; 136 137 $org_page_text = $this->get_org_page_text(); 138 139 $event->data['tpl'] = $this->deepl_translate($org_page_text, $this->langs[$this->get_target_lang()]); 140 } 141 142 private function push_translate(Doku_Event $event): void { 143 global $ID; 144 145 // check if action is translate 146 if ($event->data != 'translate') return; 147 148 // check if button is enabled 149 if (!$this->getConf('show_button')) { 150 send_redirect(wl($ID)); 151 return; 152 } 153 154 if (!$this->check_do_push_translate()) { 155 send_redirect(wl($ID)); 156 return; 157 } 158 159 // push translate 160 $push_langs = $this->get_push_langs(); 161 $org_page_text = rawWiki($ID); 162 foreach ($push_langs as $lang) { 163 // skip invalid languages 164 if (!array_key_exists($lang, $this->langs)) { 165 msg($this->getLang('msg_translation_fail_invalid_lang') . $lang, -1); 166 continue; 167 } 168 169 $lang_id = $lang . ':' . $ID; 170 171 // check permissions 172 $perm = auth_quickaclcheck($ID); 173 $exists = page_exists($lang_id); 174 if (($exists and $perm < AUTH_EDIT) or (!$exists and $perm < AUTH_CREATE)) { 175 msg($this->getLang('msg_translation_fail_no_permissions') . $lang_id, -1); 176 continue; 177 } 178 179 $translated_text = $this->deepl_translate($org_page_text, $this->langs[$lang]); 180 saveWikiText($lang_id, $translated_text, 'Automatic push translation'); 181 } 182 183 msg($this->getLang('msg_translation_success'), 1); 184 185 // reload the page after translation to clear the action 186 send_redirect(wl($ID)); 187 } 188 189 private function get_mode(): string { 190 global $ID; 191 if ($this->getConf('editor_regex')) { 192 if (preg_match('/' . $this->getConf('editor_regex') . '/', $ID) === 1) return 'editor'; 193 } 194 if ($this->getConf('direct_regex')) { 195 if (preg_match('/' . $this->getConf('direct_regex') . '/', $ID) === 1) return 'direct'; 196 } 197 return $this->getConf('mode'); 198 } 199 200 private function get_target_lang(): string { 201 global $ID; 202 $split_id = explode(':', $ID); 203 return array_shift($split_id); 204 } 205 206 private function get_org_page_text(): string { 207 global $ID; 208 209 $split_id = explode(':', $ID); 210 array_shift($split_id); 211 $org_id = implode(':', $split_id); 212 213 return rawWiki($org_id); 214 } 215 216 private function check_do_translation($allow_existing = false): bool { 217 global $INFO; 218 global $ID; 219 220 // only translate if the current page does not exist 221 if ($INFO['exists'] and !$allow_existing) return false; 222 223 // permission check 224 $perm = auth_quickaclcheck($ID); 225 if (($INFO['exists'] and $perm < AUTH_EDIT) or (!$INFO['exists'] and $perm < AUTH_CREATE)) return false; 226 227 // skip blacklisted namespaces and pages 228 if ($this->getConf('blacklist_regex')) { 229 if (preg_match('/' . $this->getConf('blacklist_regex') . '/', $ID) === 1) return false; 230 } 231 232 $split_id = explode(':', $ID); 233 $lang_ns = array_shift($split_id); 234 // only translate if the current page is in a language namespace 235 if (!array_key_exists($lang_ns, $this->langs)) return false; 236 237 $org_id = implode(':', $split_id); 238 // check if the original page exists 239 if (!page_exists($org_id)) return false; 240 241 return true; 242 } 243 244 private function check_do_push_translate(): bool { 245 global $ID; 246 247 $push_langs = $this->get_push_langs(); 248 // push_langs empty --> push_translate disabled --> abort 249 if (empty($push_langs)) return false; 250 251 // skip blacklisted namespaces and pages 252 if ($this->getConf('blacklist_regex')) { 253 // blacklist regex match --> abort 254 if (preg_match('/' . $this->getConf('blacklist_regex') . '/', $ID) === 1) return false; 255 } 256 257 return true; 258 } 259 260 private function deepl_translate($text, $target_lang): string { 261 if (!trim($this->getConf('api_key'))) return ''; 262 263 $text = $this->insert_ignore_tags($text); 264 265 $data = [ 266 'auth_key' => $this->getConf('api_key'), 267 'target_lang' => $target_lang, 268 'tag_handling' => 'xml', 269 'ignore_tags' => 'ignore,php', 270 'text' => $text 271 ]; 272 273 if ($this->getConf('api') == 'free') { 274 $url = 'https://api-free.deepl.com/v2/translate'; 275 } else { 276 $url = 'https://api.deepl.com/v2/translate'; 277 } 278 279 $http = new DokuHTTPClient(); 280 $raw_response = $http->post($url, $data); 281 282 if ($http->status >= 400) { 283 // add error messages 284 switch ($http->status) { 285 case 403: 286 msg($this->getLang('msg_translation_fail_bad_key'), -1); 287 break; 288 case 456: 289 msg($this->getLang('msg_translation_fail_quota_exceeded'), -1); 290 break; 291 default: 292 msg($this->getLang('msg_translation_fail'), -1); 293 break; 294 } 295 296 // if any error occurred return an empty string 297 return ''; 298 } 299 300 $json_response = json_decode($raw_response, true); 301 $translated_text = $json_response['translations'][0]['text']; 302 303 $translated_text = $this->remove_ignore_tags($translated_text); 304 305 return $translated_text; 306 } 307 308 private function get_push_langs(): array { 309 $push_langs = trim($this->getConf('push_langs')); 310 311 if ($push_langs === '') return array(); 312 313 return explode(' ', $push_langs); 314 } 315 316 private function insert_ignore_tags($text): string { 317 $text = str_replace('[[', '<ignore>[[', $text); 318 $text = str_replace('{{', '<ignore>{{', $text); 319 $text = str_replace(']]', ']]</ignore>', $text); 320 $text = str_replace('}}', '}}</ignore>', $text); 321 $text = str_replace("''", "<ignore>''</ignore>", $text); 322 323 $text = preg_replace('/(<file[\s\S]*?>[\s\S]*?<\/file>)/', '<ignore>${1}</ignore>', $text); 324 $text = preg_replace('/(<code[\s\S]*?>[\s\S]*?<\/code>)/', '<ignore>${1}</ignore>', $text); 325 326 $ignored_expressions = explode(':', $this->getConf('ignored_expressions')); 327 328 foreach ($ignored_expressions as $expression) { 329 $text = str_replace($expression, '<ignore>' . $expression . '</ignore>', $text); 330 } 331 332 return $text; 333 } 334 335 private function remove_ignore_tags($text): string { 336 $text = str_replace('<ignore>[[', '[[', $text); 337 $text = str_replace('<ignore>{{', '{{', $text); 338 $text = str_replace(']]</ignore>', ']]', $text); 339 $text = str_replace('}}</ignore>', '}}', $text); 340 $text = str_replace("<ignore>''</ignore>", "''", $text); 341 342 $text = preg_replace('/<ignore>(<file[\s\S]*?>[\s\S]*?<\/file>)<\/ignore>/', '${1}', $text); 343 $text = preg_replace('/<ignore>(<code[\s\S]*?>[\s\S]*?<\/code>)<\/ignore>/', '${1}', $text); 344 345 // restore < and > for example from arrows (-->) in wikitext 346 $text = str_replace('>', '>', $text); 347 $text = str_replace('<', '<', $text); 348 349 $ignored_expressions = explode(':', $this->getConf('ignored_expressions')); 350 351 foreach ($ignored_expressions as $expression) { 352 $text = str_replace('<ignore>' . $expression . '</ignore>', $expression, $text); 353 } 354 355 return $text; 356 } 357} 358 359