Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.35% covered (success)
98.35%
119 / 121
84.62% covered (warning)
84.62%
11 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
LanguageHelper
98.35% covered (success)
98.35%
119 / 121
84.62% covered (warning)
84.62%
11 / 13
42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getHelpFiles
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 getLanguages
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 findMissingLanguageStrings
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 compareLanguages
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getLangName
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 getTextDomains
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
8.02
 loadLanguage
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 findDuplicatedValues
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getLanguageDetails
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getAllLanguageDetails
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 summarizeData
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
5
 getAllDetails
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Language Helper for Development Tools Controller
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2015.
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  DevTools
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/wiki/indexing:alphabetical_heading_browse Wiki
28 */
29
30namespace VuFindDevTools;
31
32use Laminas\I18n\Translator\TextDomain;
33use VuFind\I18n\Translator\Loader\ExtendedIni;
34
35use function count;
36use function in_array;
37
38/**
39 * Language Helper for Development Tools Controller
40 *
41 * @category VuFind
42 * @package  DevTools
43 * @author   Demian Katz <demian.katz@villanova.edu>
44 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
45 * @link     https://vufind.org/wiki/indexing:alphabetical_heading_browse Wiki
46 */
47class LanguageHelper
48{
49    /**
50     * Language loader
51     *
52     * @var ExtendedIni
53     */
54    protected $loader;
55
56    /**
57     * Configured languages (code => description)
58     *
59     * @var string[]
60     */
61    protected $configuredLanguages;
62
63    /**
64     * Constructor
65     *
66     * @param ExtendedIni $loader Language loader
67     * @param array       $langs  Configured languages (code => description)
68     */
69    public function __construct(ExtendedIni $loader, array $langs = [])
70    {
71        $this->loader = $loader;
72        $this->configuredLanguages = $langs;
73    }
74
75    /**
76     * Get a list of help files in the specified language.
77     *
78     * @param string $language Language to check.
79     *
80     * @return array
81     */
82    protected function getHelpFiles($language)
83    {
84        $dir = APPLICATION_PATH
85            . '/themes/root/templates/HelpTranslations/' . $language;
86        if (!file_exists($dir) || !is_dir($dir)) {
87            return [];
88        }
89        $handle = opendir($dir);
90        $files = [];
91        while ($file = readdir($handle)) {
92            if (str_ends_with($file, '.phtml')) {
93                $files[] = $file;
94            }
95        }
96        closedir($handle);
97        return $files;
98    }
99
100    /**
101     * Get a list of languages supported by VuFind:
102     *
103     * @return array
104     */
105    protected function getLanguages(): array
106    {
107        $langs = [];
108        $dir = opendir(APPLICATION_PATH . '/languages');
109        while ($file = readdir($dir)) {
110            if (str_ends_with($file, '.ini')) {
111                $lang = current(explode('.', $file));
112                if ('native' != $lang) {
113                    $langs[] = $lang;
114                }
115            }
116        }
117        closedir($dir);
118        return $langs;
119    }
120
121    /**
122     * Find strings that are absent from a language file.
123     *
124     * @param TextDomain $lang1 Left side of comparison
125     * @param TextDomain $lang2 Right side of comparison
126     *
127     * @return array
128     */
129    protected function findMissingLanguageStrings(TextDomain $lang1, TextDomain $lang2): array
130    {
131        // Find strings missing from language 2:
132        return array_values(
133            array_diff(array_keys((array)$lang1), array_keys((array)$lang2))
134        );
135    }
136
137    /**
138     * Compare two languages and return an array of details about how they differ.
139     *
140     * @param TextDomain $lang1          Left side of comparison
141     * @param TextDomain $lang2          Right side of comparison
142     * @param TextDomain $lang1NoAliases Left side of comparison (with aliases disabled)
143     * @param TextDomain $lang2NoAliases Right side of comparison (with aliases disabled)
144     *
145     * @return array
146     */
147    public function compareLanguages(
148        TextDomain $lang1,
149        TextDomain $lang2,
150        TextDomain $lang1NoAliases,
151        TextDomain $lang2NoAliases
152    ): array {
153        // We don't want to double-count aliased terms, nor do we want to count alias
154        // overrides as "extra lines". Thus, we find meaningful differences by subtracting
155        // the aliased data of one language from the non-aliased data of the other.
156        return [
157            'notInL1' => $this->findMissingLanguageStrings($lang2NoAliases, $lang1),
158            'notInL2' => $this->findMissingLanguageStrings($lang1NoAliases, $lang2),
159            'l1Percent' => number_format(count($lang1) / count($lang2) * 100, 2),
160            'l2Percent' => number_format(count($lang2) / count($lang1) * 100, 2),
161        ];
162    }
163
164    /**
165     * Get English name of language
166     *
167     * @param string $lang Language code
168     *
169     * @return string
170     */
171    public function getLangName($lang)
172    {
173        if (isset($this->configuredLanguages[$lang])) {
174            return $this->configuredLanguages[$lang];
175        }
176        switch ($lang) {
177            case 'en-gb':
178                return 'British English';
179            case 'pt-br':
180                return 'Brazilian Portuguese';
181            default:
182                return $lang;
183        }
184    }
185
186    /**
187     * Get text domains for a language.
188     *
189     * @param bool $includeOptional Include optional translations (e.g. DDC23)
190     *
191     * @return array
192     */
193    protected function getTextDomains($includeOptional)
194    {
195        static $domains = false;
196        if (!$domains) {
197            $filter = $includeOptional
198                ? []
199                : ['CallNumberFirst', 'CreatorRoles', 'DDC23', 'ISO639-3'];
200            $base = APPLICATION_PATH . '/languages';
201            $dir = opendir($base);
202            $domains = [];
203            while ($current = readdir($dir)) {
204                if (
205                    $current != '.' && $current != '..'
206                    && is_dir("$base/$current")
207                    && !in_array($current, $filter)
208                ) {
209                    $domains[] = $current;
210                }
211            }
212            closedir($dir);
213        }
214        return $domains;
215    }
216
217    /**
218     * Load a language, including text domains.
219     *
220     * @param string $lang            Language to load
221     * @param bool   $includeOptional Include optional translations (e.g. DDC23)
222     * @param bool   $includeAliases  Include alias details
223     *
224     * @return array
225     */
226    protected function loadLanguage($lang, $includeOptional, $includeAliases = true)
227    {
228        $includeAliases ? $this->loader->enableAliases() : $this->loader->disableAliases();
229        $base = $this->loader->load($lang, null);
230        foreach ($this->getTextDomains($includeOptional) as $domain) {
231            $current = $this->loader->load($lang, $domain);
232            foreach ($current as $k => $v) {
233                if ($k != '@parent_ini') {
234                    $base["$domain::$k"] = $v;
235                }
236            }
237        }
238        if (isset($base['@parent_ini'])) {
239            // don't count macros in comparison:
240            unset($base['@parent_ini']);
241        }
242        return $base;
243    }
244
245    /**
246     * Find duplicated values within the language.
247     *
248     * @param TextDomain $lang Language to analyze.
249     *
250     * @return array
251     */
252    protected function findDuplicatedValues(TextDomain $lang): array
253    {
254        $index = [];
255        foreach ($lang as $key => $val) {
256            $index[$val] = array_merge($index[$val] ?? [], [$key]);
257        }
258        $callback = function ($set) {
259            return count($set) > 1;
260        };
261        return array_filter($index, $callback);
262    }
263
264    /**
265     * Return details on how $langCode differs from $main.
266     *
267     * @param TextDomain $main            The main language (full details)
268     * @param TextDomain $mainNoAliases   The main language (with aliases disabled)
269     * @param string     $langCode        The code of a language to compare against $main
270     * @param bool       $includeOptional Include optional translations (e.g. DDC23)
271     *
272     * @return array
273     */
274    protected function getLanguageDetails(
275        TextDomain $main,
276        TextDomain $mainNoAliases,
277        string $langCode,
278        bool $includeOptional
279    ): array {
280        $lang = $this->loadLanguage($langCode, $includeOptional);
281        $langNoAliases = $this->loadLanguage($langCode, $includeOptional, false);
282        $details = $this->compareLanguages($main, $lang, $mainNoAliases, $langNoAliases);
283        $details['dupes'] = $this->findDuplicatedValues($langNoAliases);
284        $details['object'] = $lang;
285        $details['name'] = $this->getLangName($langCode);
286        $details['helpFiles'] = $this->getHelpFiles($langCode);
287        return $details;
288    }
289
290    /**
291     * Return details on how all languages differ from $main.
292     *
293     * @param TextDomain $main            The main language (full details)
294     * @param TextDomain $mainNoAliases   The main language (with aliases disabled)
295     * @param bool       $includeOptional Include optional translations (e.g. DDC23)
296     *
297     * @return array
298     */
299    protected function getAllLanguageDetails(TextDomain $main, TextDomain $mainNoAliases, bool $includeOptional): array
300    {
301        $details = [];
302        $allLangs = $this->getLanguages();
303        sort($allLangs);
304        foreach ($allLangs as $langCode) {
305            $details[$langCode] = $this
306                ->getLanguageDetails($main, $mainNoAliases, $langCode, $includeOptional);
307        }
308        return $details;
309    }
310
311    /**
312     * Create summary data for use in the tabular display.
313     *
314     * @param array $details Full details from getAllLanguageDetails()
315     *
316     * @return array
317     */
318    protected function summarizeData($details)
319    {
320        $data = [];
321        foreach ($details as $langCode => $diffs) {
322            if ($diffs['l2Percent'] > 90) {
323                $progressLevel = 'info';
324            } elseif ($diffs['l2Percent'] > 70) {
325                $progressLevel = 'warning';
326            } else {
327                $progressLevel = 'danger';
328            }
329            $data[] = [
330                'lang' => $langCode,
331                'name' => $diffs['name'],
332                'dupes' => $diffs['dupes'],
333                'langtitle' => $langCode . (($langCode != $diffs['name'])
334                    ? ' (' . $diffs['name'] . ')' : ''),
335                'missing' => count($diffs['notInL2']),
336                'extra' => count($diffs['notInL1']),
337                'percent' => $diffs['l2Percent'],
338                'countfiles' => count($diffs['helpFiles']),
339                'files' => $diffs['helpFiles'],
340                'progresslevel' => $progressLevel,
341            ];
342        }
343        return $data;
344    }
345
346    /**
347     * Return language comparison information, using $mainLanguage as the
348     * baseline.
349     *
350     * @param string $mainLanguage    Language code
351     * @param bool   $includeOptional Include optional translations (e.g. DDC23)
352     *
353     * @return array
354     */
355    public function getAllDetails($mainLanguage, $includeOptional = true)
356    {
357        $main = $this->loadLanguage($mainLanguage, $includeOptional);
358        $mainNoAliases = $this->loadLanguage($mainLanguage, $includeOptional, false);
359        $details = $this->getAllLanguageDetails($main, $mainNoAliases, $includeOptional);
360        $dirHelpParts = [
361            APPLICATION_PATH, 'themes', 'root', 'templates', 'HelpTranslations',
362        ];
363        $dirLangParts = [APPLICATION_PATH, 'languages'];
364        return compact('details', 'main', 'includeOptional') + [
365            'dirHelp' => implode(DIRECTORY_SEPARATOR, $dirHelpParts)
366                . DIRECTORY_SEPARATOR,
367            'dirLang' => implode(DIRECTORY_SEPARATOR, $dirLangParts)
368                . DIRECTORY_SEPARATOR,
369            'mainCode' => $mainLanguage,
370            'mainName' => $this->getLangName($mainLanguage),
371            'summaryData' => $this->summarizeData($details),
372        ];
373    }
374}