Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.93% covered (warning)
81.93%
68 / 83
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
TranslatorAwareTrait
81.93% covered (warning)
81.93%
68 / 83
66.67% covered (warning)
66.67%
6 / 9
42.23
0.00% covered (danger)
0.00%
0 / 1
 setTranslator
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getTranslator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTranslatorLocale
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getDebugTranslation
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 translate
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
8
 translateWithPrefix
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 translateString
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 extractTextDomain
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
8
 sanitizeTranslationKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Reusable implementation of TranslatorAwareInterface.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Translator
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org Main Site
28 */
29
30namespace VuFind\I18n\Translator;
31
32use Laminas\I18n\Translator\TranslatorInterface;
33
34use function count;
35use function is_array;
36use function is_callable;
37use function is_string;
38
39/**
40 * Reusable implementation of TranslatorAwareInterface.
41 *
42 * @category VuFind
43 * @package  Translator
44 * @author   Demian Katz <demian.katz@villanova.edu>
45 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
46 * @link     https://vufind.org Main Site
47 */
48trait TranslatorAwareTrait
49{
50    /**
51     * Translator
52     *
53     * @var \Laminas\I18n\Translator\TranslatorInterface
54     */
55    protected $translator = null;
56
57    /**
58     * Set a translator
59     *
60     * @param TranslatorInterface $translator Translator
61     *
62     * @return TranslatorAwareInterface
63     */
64    public function setTranslator(TranslatorInterface $translator)
65    {
66        $this->translator = $translator;
67        return $this;
68    }
69
70    /**
71     * Get translator object.
72     *
73     * @return \Laminas\I18n\Translator\TranslatorInterface
74     */
75    public function getTranslator()
76    {
77        return $this->translator;
78    }
79
80    /**
81     * Get the locale from the translator.
82     *
83     * @param string $default Default to use if translator absent.
84     *
85     * @return string
86     */
87    public function getTranslatorLocale($default = 'en')
88    {
89        return null !== $this->translator
90            && is_callable([$this->translator, 'getLocale'])
91            ? $this->translator->getLocale()
92            : $default;
93    }
94
95    /**
96     * Build a debug-mode translation
97     *
98     * @param string $domain Text domain
99     * @param string $str    String to translate
100     * @param array  $tokens Tokens to inject into the translated string
101     *
102     * @return string
103     */
104    protected function getDebugTranslation(string $domain, string $str, array $tokens): string
105    {
106        $targetString = $domain !== 'default' ? "$domain::$str" : $str;
107        $keyValueToString = function ($key, $val) {
108            return "$key = $val";
109        };
110        $tokenDetails = empty($tokens)
111            ? ''
112            : ' | [' .
113            implode(', ', array_map($keyValueToString, array_keys($tokens), array_values($tokens))) .
114            ']';
115        return "*$targetString$tokenDetails*";
116    }
117
118    /**
119     * Translate a string (or string-castable object)
120     *
121     * @param string|object|array $target          String to translate or an array of text
122     *                                             domain and string to translate
123     * @param array               $tokens          Tokens to inject into the translated string
124     * @param string              $default         Default value to use if no translation is
125     *                                             found (null for no default).
126     * @param bool                $useIcuFormatter Should we use an ICU message formatter instead
127     * of the default behavior?
128     * @param string[]            $fallbackDomains Text domains to check if no match is found in
129     * the domain specified in $target
130     *
131     * @return string
132     */
133    public function translate(
134        $target,
135        $tokens = [],
136        $default = null,
137        $useIcuFormatter = false,
138        $fallbackDomains = []
139    ) {
140        // Figure out the text domain for the string:
141        [$domain, $str] = $this->extractTextDomain($target);
142
143        if ($this->getTranslatorLocale() == 'debug') {
144            return $this->getDebugTranslation($domain, $str, $tokens);
145        }
146
147        // Special case: deal with objects with a designated display value:
148        if ($str instanceof \VuFind\I18n\TranslatableStringInterface) {
149            if (!$str->isTranslatable()) {
150                return $str->getDisplayString();
151            }
152            // On this pass, don't use the $default, since we want to fail over
153            // to getDisplayString before giving up:
154            $translated = $this
155                ->translateString((string)$str, $tokens, null, $domain, $useIcuFormatter);
156            if ($translated !== (string)$str) {
157                return $translated;
158            }
159            // Override $domain/$str using getDisplayString() before proceeding:
160            $str = $str->getDisplayString();
161            // Also the display string can be a TranslatableString. This makes it
162            // possible have multiple levels of translatable values while still
163            // providing a sane default string if translation is not found. Used at
164            // least with hierarchical facets where translation key can be the exact
165            // facet value (e.g. "0/Book/") or a displayable value (e.g. "Book").
166            if ($str instanceof \VuFind\I18n\TranslatableStringInterface) {
167                return $this->translate($str, $tokens, $default, $useIcuFormatter);
168            } else {
169                [$domain, $str] = $this->extractTextDomain($str);
170            }
171        }
172
173        // Default case: deal with ordinary strings (or string-castable objects):
174        $translation = $this->translateString((string)$str, $tokens, $default, $domain, $useIcuFormatter);
175        // If we have fallback domains, apply them now:
176        while ($translation === (string)($default ?? $str) && !empty($fallbackDomains)) {
177            $domain = array_shift($fallbackDomains);
178            $translation = $this->translateString(
179                (string)$str,
180                $tokens,
181                $default,
182                $domain,
183                $useIcuFormatter
184            );
185        }
186        return $translation;
187    }
188
189    /**
190     * Translate a string (or string-castable object) using a prefix, or without the
191     * prefix if a prefixed translation is not found.
192     *
193     * @param string              $prefix          Translation key prefix
194     * @param string|object|array $target          String to translate or an array of text
195     *                                             domain and string to translate
196     * @param array               $tokens          Tokens to inject into the translated string
197     * @param string              $default         Default value to use if no translation is
198     *                                             found (null for no default).
199     * @param bool                $useIcuFormatter Should we use an ICU message formatter instead
200     * of the default behavior?
201     * @param string[]            $fallbackDomains Text domains to check if no match is found in
202     * the domain specified in $target
203     *
204     * @return string
205     */
206    public function translateWithPrefix(
207        $prefix,
208        $target,
209        $tokens = [],
210        $default = null,
211        $useIcuFormatter = false,
212        $fallbackDomains = []
213    ) {
214        if (is_string($target)) {
215            if (null === $default) {
216                $default = $target;
217            }
218            $target = $prefix . $target;
219        }
220        return $this->translate($target, $tokens, $default, $useIcuFormatter, $fallbackDomains);
221    }
222
223    /**
224     * Get translation for a string
225     *
226     * @param string $rawStr          String to translate
227     * @param array  $tokens          Tokens to inject into the translated string
228     * @param string $default         Default value to use if no translation is found
229     *                                (null for no default).
230     * @param string $domain          Text domain (omit for default)
231     * @param bool   $useIcuFormatter Should we use an ICU message formatter instead
232     * of the default behavior?
233     *
234     * @return string
235     */
236    protected function translateString(
237        $rawStr,
238        $tokens = [],
239        $default = null,
240        $domain = 'default',
241        $useIcuFormatter = false
242    ) {
243        if (null === $this->translator) {
244            $msg = $str = $rawStr;
245        } else {
246            $str = $this->sanitizeTranslationKey($rawStr);
247            $msg = $this->translator->translate($str, $domain);
248        }
249
250        // Did the translation fail to change anything?  If so, use default:
251        if ($msg == $str) {
252            $finalDefault = $default ?? $rawStr;
253            $msg = $finalDefault instanceof \VuFind\I18n\TranslatableStringInterface
254                ? $finalDefault->getDisplayString() : $finalDefault;
255        }
256
257        // Do we need to perform substitutions?
258        if (!empty($tokens)) {
259            if ($useIcuFormatter) {
260                return \MessageFormatter::formatMessage($this->getTranslatorLocale(), $msg, $tokens);
261            }
262            $in = $out = [];
263            foreach ($tokens as $key => $value) {
264                $in[] = $key;
265                $out[] = $value;
266            }
267            $msg = str_replace($in, $out, $msg);
268        }
269
270        return $msg;
271    }
272
273    /**
274     * Given a translation string with or without a text domain, return an
275     * array with the raw string and the text domain separated.
276     *
277     * @param string|object|array $target String to translate or an array of text
278     * domain and string to translate
279     *
280     * @return array
281     */
282    protected function extractTextDomain($target)
283    {
284        $parts = is_array($target) ? $target : explode('::', $target, 2);
285        if (count($parts) < 1 || count($parts) > 2) {
286            throw new \Exception('Unexpected value sent to translator!');
287        }
288        if (count($parts) == 2) {
289            if (empty($parts[0])) {
290                $parts[0] = 'default';
291            }
292            if ($target instanceof \VuFind\I18n\TranslatableStringInterface) {
293                $class = $target::class;
294                $parts[1] = new $class(
295                    $parts[1],
296                    $target->getDisplayString(),
297                    $target->isTranslatable()
298                );
299            }
300            return $parts;
301        }
302        return ['default', is_array($target) ? $parts[0] : $target];
303    }
304
305    /**
306     * Make sure there are not any illegal characters in the translation key
307     * that might prevent successful lookup in language files.
308     *
309     * @param string $key Key to sanitize
310     *
311     * @return string Sanitized key
312     */
313    protected function sanitizeTranslationKey(string $key): string
314    {
315        // The characters ()!?| are not allowed in keys in the Lokalise translation
316        // platform, so they should not be allowed in our code. We'll replace them
317        // with underscore-prefixed, urlencode-inspired codes so that translations
318        // can still be provided if the input cannot be changed (e.g. if it comes
319        // from a third-party system).
320        return str_replace(
321            ['(', ')', '!', '?', '|'],
322            ['_28', '_29', '_21', '_3F', '_7C'],
323            $key
324        );
325    }
326}