1<?php
2
3use dokuwiki\Extension\Plugin;
4use dokuwiki\ChangeLog\PageChangeLog;
5
6/**
7 * Translation Plugin: Simple multilanguage plugin
8 *
9 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
10 * @author     Andreas Gohr <andi@splitbrain.org>
11 */
12class helper_plugin_translation extends Plugin
13{
14    public $translations = [];
15    public $translationNs = '';
16    public $defaultlang = '';
17    public $LN = []; // hold native names
18    public $opts = []; // display options
19
20    /**
21     * Initialize
22     */
23    public function __construct()
24    {
25        global $conf;
26        require_once(DOKU_INC . 'inc/pageutils.php');
27        require_once(DOKU_INC . 'inc/utf8.php');
28
29        $this->loadTranslationNamespaces();
30
31        // load language names
32        $this->LN = confToHash(__DIR__ . '/lang/langnames.txt');
33
34        // display options
35        $this->opts = $this->getConf('display');
36        $this->opts = explode(',', $this->opts);
37        $this->opts = array_map('trim', $this->opts);
38        $this->opts = array_fill_keys($this->opts, true);
39
40        // get default translation
41        if (empty($conf['lang_before_translation'])) {
42            $dfl = $conf['lang'];
43        } else {
44            $dfl = $conf['lang_before_translation'];
45        }
46        if (in_array($dfl, $this->translations)) {
47            $this->defaultlang = $dfl;
48        } else {
49            $this->defaultlang = '';
50            array_unshift($this->translations, '');
51        }
52
53        $this->translationNs = cleanID($this->getConf('translationns'));
54        if ($this->translationNs) $this->translationNs .= ':';
55    }
56
57    /**
58     * Parse 'translations'-setting into $this->translations
59     */
60    public function loadTranslationNamespaces()
61    {
62        // load wanted translation into array
63        $this->translations = strtolower(str_replace(',', ' ', $this->getConf('translations')));
64        $this->translations = array_unique(array_filter(explode(' ', $this->translations)));
65        sort($this->translations);
66    }
67
68    /**
69     * Check if the given ID is a translation and return the language code.
70     *
71     * @param string $id
72     * @return string
73     */
74    public function getLangPart($id)
75    {
76        [$lng] = $this->getTransParts($id);
77        return $lng;
78    }
79
80    /**
81     * Check if the given ID is a translation and return the language code and
82     * the id part.
83     *
84     * @param string $id
85     * @return array
86     */
87    public function getTransParts($id)
88    {
89        $rx = '/^' . $this->translationNs . '(' . implode('|', $this->translations) . '):(.*)/';
90        if (preg_match($rx, $id, $match)) {
91            return [$match[1], $match[2]];
92        }
93        return ['', $id];
94    }
95
96    /**
97     * Returns the browser language if it matches with one of the configured
98     * languages
99     */
100    public function getBrowserLang()
101    {
102        global $conf;
103        $langs = $this->translations;
104        if (!in_array($conf['lang'], $langs)) {
105            $langs[] = $conf['lang'];
106        }
107        $rx = '/(^|,|:|;|-)(' . implode('|', $langs) . ')($|,|:|;|-)/i';
108        if (preg_match($rx, $_SERVER['HTTP_ACCEPT_LANGUAGE'], $match)) {
109            return strtolower($match[2]);
110        }
111        return false;
112    }
113
114    /**
115     * Returns the ID and name to the wanted translation, empty
116     * $lng is default lang
117     *
118     * @param string $lng
119     * @param string $idpart
120     * @return array
121     */
122    public function buildTransID($lng, $idpart)
123    {
124        if ($lng && in_array($lng, $this->translations)) {
125            $link = ':' . $this->translationNs . $lng . ':' . $idpart;
126            $name = $lng;
127        } else {
128            $link = ':' . $this->translationNs . $idpart;
129            $name = $this->realLC('');
130        }
131        return [$link, $name];
132    }
133
134    /**
135     * Returns the real language code, even when an empty one is given
136     * (eg. resolves th default language)
137     *
138     * @param string $lc
139     * @return string
140     */
141    public function realLC($lc)
142    {
143        global $conf;
144        if ($lc) {
145            return $lc;
146        } elseif (empty($conf['lang_before_translation'])) {
147            return $conf['lang'];
148        } else {
149            return $conf['lang_before_translation'];
150        }
151    }
152
153    /**
154     * Check if current ID should be translated and any GUI
155     * should be shown
156     *
157     * @param string $id
158     * @param bool $checkact only return true if $ACT is 'show'
159     * @return bool
160     */
161    public function istranslatable($id, $checkact = true)
162    {
163        global $ACT;
164
165        if ($checkact && (!isset($ACT) || act_clean($ACT) != 'show')) return false;
166        if ($this->translationNs && strpos($id, (string) $this->translationNs) !== 0) return false;
167        $skiptrans = trim($this->getConf('skiptrans'));
168        if ($skiptrans && preg_match('/' . $skiptrans . '/ui', ':' . $id)) return false;
169        $meta = p_get_metadata($id);
170        if (!empty($meta['plugin']['translation']['notrans'])) return false;
171
172        return true;
173    }
174
175    /**
176     * Return the (localized) about link
177     */
178    public function showAbout()
179    {
180        global $ID;
181
182        $curlc = $this->getLangPart($ID);
183
184        $about = $this->getConf('about');
185        if ($this->getConf('localabout')) {
186            [, $idpart] = $this->getTransParts($about);
187            [$about, ] = $this->buildTransID($curlc, $idpart);
188            $about = cleanID($about);
189        }
190
191        $out = '<sup>';
192        $out .= html_wikilink($about, '?');
193        $out .= '</sup>';
194
195        return $out;
196    }
197
198    /**
199     * Returns a list of (lc => link) for all existing translations of a page
200     *
201     * @param $id
202     * @return array
203     */
204    public function getAvailableTranslations($id)
205    {
206        $result = [];
207
208        [$lc, $idpart] = $this->getTransParts($id);
209
210        foreach ($this->translations as $t) {
211            if ($t == $lc) continue; //skip self
212            [$link, $name] = $this->buildTransID($t, $idpart);
213            if (page_exists($link)) {
214                $result[$name] = $link;
215            }
216        }
217
218        return $result;
219    }
220
221    /**
222     * Creates an UI for linking to the available and configured translations
223     *
224     * Can be called from the template or via the ~~TRANS~~ syntax component.
225     *
226     * @param string $checkage (note that checkAge() should be called anyway at some point)
227     */
228    public function showTranslations($checkage = true)
229    {
230        global $INFO;
231
232        if (!$this->istranslatable($INFO['id'])) return '';
233        if ($checkage) $this->checkage();
234
235        [, $idpart] = $this->getTransParts($INFO['id']);
236
237        $out = '<div class="plugin_translation ' . ($this->getConf('dropdown') ? 'is-dropdown' : '') . '">';
238
239        //show title and about
240        if (isset($this->opts['title']) || $this->getConf('about')) {
241            $out .= '<span class="title">';
242            if (isset($this->opts['title'])) $out .= $this->getLang('translations');
243            if ($this->getConf('about')) $out .= $this->showAbout();
244            if (isset($this->opts['title'])) $out .= ': ';
245            $out .= '</span>';
246        }
247
248        $out .= '<ul>';
249        foreach ($this->translations as $t) {
250            [$type, $text, $attr] = $this->prepareLanguageSelectorItem($t, $idpart, $INFO['id']);
251            $out .= '<li class="' . $type . '">';
252            $out .= "<$type " . buildAttributes($attr) . ">$text</$type>";
253            $out .= '</li>';
254        }
255        $out .= '</ul>';
256
257        $out .= '</div>';
258
259        return $out;
260    }
261
262    /**
263     * Return the local name
264     *
265     * @param $lang
266     * @return string
267     */
268    public function getLocalName($lang)
269    {
270        return $this->LN[$lang] ?? $lang;
271    }
272
273    /**
274     * Create a single language selector item
275     *
276     * @param string $lc The language code of the item
277     * @param string $idpart The ID part of the item
278     * @param string $current The current ID
279     * @return array [$type, $text, $attr]
280     */
281    protected function prepareLanguageSelectorItem($lc, $idpart, $current)
282    {
283        [$target, $lang] = $this->buildTransID($lc, $idpart);
284        $target = cleanID($target);
285        $exists = page_exists($target, '', false);
286
287        $text = '';
288        $attr = [
289            'class' => $exists ? 'wikilink1' : 'wikilink2',
290            'title' => $this->getLocalName($lang),
291        ];
292
293        // no link on current page
294        if ($current === $target) {
295            $type = 'span';
296        } else {
297            $type = 'a';
298            $attr['href'] = wl($target);
299        }
300
301        // add flag
302        if (isset($this->opts['flag'])) {
303            $text .= '<i>' . inlineSVG(DOKU_PLUGIN . 'translation/flags/' . $lang . '.svg', 1024 * 12) . '</i>';
304        }
305
306        // decide what to show
307        if (isset($this->opts['name'])) {
308            $text .= hsc($this->getLocalName($lang));
309            if (isset($this->opts['langcode'])) $text .= ' (' . hsc($lang) . ')';
310        } elseif (isset($this->opts['langcode'])) {
311            $text .= hsc($lang);
312        }
313
314        return [$type, $text, $attr];
315    }
316
317    /**
318     * Checks if the current page is a translation of a page
319     * in the default language. Displays a notice when it is
320     * older than the original page. Tries to link to a diff
321     * with changes on the original since the translation
322     */
323    public function checkage()
324    {
325        global $ID;
326        global $INFO;
327        if (!$this->getConf('checkage')) return;
328        if (!$INFO['exists']) return;
329        $lng = $this->getLangPart($ID);
330        if ($lng == $this->defaultlang) return;
331
332        $rx = '/^' . $this->translationNs . '((' . implode('|', $this->translations) . '):)?/';
333        $idpart = preg_replace($rx, '', $ID);
334
335        // compare modification times
336        [$orig, ] = $this->buildTransID($this->defaultlang, $idpart);
337        $origfn = wikiFN($orig);
338        if ($INFO['lastmod'] >= @filemtime($origfn)) return;
339
340        // build the message and display it
341        $orig = cleanID($orig);
342        $msg = sprintf($this->getLang('outdated'), wl($orig));
343
344        $difflink = $this->getOldDiffLink($orig, $INFO['lastmod']);
345        if ($difflink) {
346            $msg .= sprintf(' ' . $this->getLang('diff'), $difflink);
347        }
348
349        echo '<div class="notify">' . $msg . '</div>';
350    }
351
352    /**
353     * Get a link to a diff with changes on the original since the translation
354     *
355     * @param string $id
356     * @param int $lastmod
357     * @return false|string false id no diff can be found, link otherwise
358     */
359    public function getOldDiffLink($id, $lastmod)
360    {
361        // get revision from before translation
362        $orev = false;
363        $changelog = new PageChangeLog($id);
364        $revs = $changelog->getRevisions(0, 100);
365        foreach ($revs as $rev) {
366            if ($rev < $lastmod) {
367                $orev = $rev;
368                break;
369            }
370        }
371        if ($orev && !page_exists($id, $orev)) {
372            return false;
373        }
374        $id = cleanID($id);
375        return wl($id, ['do' => 'diff', 'rev' => $orev]);
376    }
377}
378