Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
60 / 60
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
ExtendedIniNormalizer
100.00% covered (success)
100.00%
60 / 60
100.00% covered (success)
100.00%
9 / 9
31
100.00% covered (success)
100.00%
1 / 1
 normalizeDirectory
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 normalizeFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeFileToString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 loadFileIntoArray
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 normalizeArray
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 formatAsString
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 extractComments
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 filenameMatchesFilter
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 checkFileFormat
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3/**
4 * Class to consistently format ExtendedIni language files.
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;
31
32use Laminas\I18n\Translator\TextDomain;
33
34use function in_array;
35
36/**
37 * Class to consistently format ExtendedIni language files.
38 *
39 * @category VuFind
40 * @package  Translator
41 * @author   Demian Katz <demian.katz@villanova.edu>
42 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
43 * @link     https://vufind.org Main Site
44 */
45class ExtendedIniNormalizer
46{
47    use \VuFind\I18n\Translator\TranslatorAwareTrait;
48
49    /**
50     * Reserved words that need to be quoted when used as keys.
51     *
52     * @var string[]
53     */
54    protected $reservedWords = ['yes'];
55
56    /**
57     * Normalize a directory on disk.
58     *
59     * @param string $dir    Directory to normalize.
60     * @param string $filter File name filter.
61     *
62     * @return void
63     */
64    public function normalizeDirectory($dir, $filter)
65    {
66        $dir = rtrim($dir, '/');
67        $handle = opendir($dir);
68        while ($file = readdir($handle)) {
69            $full = $dir . '/' . $file;
70            if ($file != '.' && $file != '..' && is_dir($full)) {
71                $this->normalizeDirectory($full, $filter);
72            } elseif ($this->filenameMatchesFilter($file, $filter)) {
73                $this->normalizeFile($full);
74            }
75        }
76        closedir($handle);
77    }
78
79    /**
80     * Normalize a file on disk.
81     *
82     * @param string $file Filename.
83     *
84     * @return void
85     */
86    public function normalizeFile($file)
87    {
88        file_put_contents($file, $this->normalizeFileToString($file));
89    }
90
91    /**
92     * Normalize a file from disk and returns the result as a string.
93     *
94     * @param string $file Filename.
95     *
96     * @return string
97     */
98    public function normalizeFileToString($file)
99    {
100        // Safeguard to avoid messing up wrong ini files:
101        $fileArray = $this->loadFileIntoArray($file);
102        $this->checkFileFormat($fileArray, $file);
103        return $this->normalizeArray($fileArray);
104    }
105
106    /**
107     * Load a language file into an array of lines, stripping UTF-8 BOM if necessary.
108     *
109     * @param string $filename File to load
110     *
111     * @return array
112     */
113    public function loadFileIntoArray(string $filename): array
114    {
115        $fileArray = file($filename);
116
117        // Strip off UTF-8 BOM if necessary.
118        if ($fileArray) {
119            $bom = html_entity_decode('&#xFEFF;', ENT_NOQUOTES, 'UTF-8');
120            $fileArray[0] = str_replace($bom, '', $fileArray[0]);
121        }
122
123        return $fileArray;
124    }
125
126    /**
127     * Normalize an array of lines from a file and return the result as a string.
128     *
129     * @param string[] $fileArray Array of lines to normalize
130     *
131     * @return string
132     */
133    public function normalizeArray(array $fileArray): string
134    {
135        // Reading and rewriting the file by itself will eliminate all comments;
136        // we should extract comments separately and then recombine the parts.
137        $comments = $this->extractComments($fileArray);
138        $reader = new Translator\Loader\ExtendedIniReader();
139        $strings = $this->formatAsString($reader->getTextDomain($fileArray, false));
140        return $comments . $strings;
141    }
142
143    /**
144     * Normalize a TextDomain or array to a string that can be written to file.
145     *
146     * @param array|TextDomain $rawInput Language values to format.
147     *
148     * @return string
149     */
150    public function formatAsString($rawInput)
151    {
152        // Sanitize keys before sorting:
153        $input = [];
154        foreach ($rawInput as $key => $value) {
155            $input[$this->sanitizeTranslationKey($key)] = $value;
156        }
157
158        // Perform a case-insensitive sort:
159        $sortCallback = function ($a, $b) {
160            // We need absolutely consistent sorting; a pure case-insensitive
161            // sort will randomly reorder strings that evaluate to the same
162            // thing (e.g. "by" vs. "By"). In our custom sort function, we'll
163            // do a case-sensitive sort on otherwise identical strings to
164            // ensure 100% consistent behavior.
165            $lowerA = strtolower($a);
166            $lowerB = strtolower($b);
167            if ($lowerA === $lowerB) {
168                return strcmp($a, $b);
169            }
170            return strcmp($lowerA, $lowerB);
171        };
172        uksort($input, $sortCallback);
173
174        // Format the lines:
175        $output = '';
176        foreach ($input as $key => $value) {
177            // Put purely numeric keys in single quotes for Lokalise compatibility:
178            $normalizedKey = is_numeric($key) || in_array($key, $this->reservedWords)
179                ? "'$key'" : $key;
180            // Choose most appropriate type of outer quotes to reduce need for escaping:
181            $quote = str_contains($value, '"') ? "'" : '"';
182            // Apply minimal escaping (to existing slashes and quotes matching the outer ones):
183            $escapedValue = str_replace(['\\', $quote], ['\\\\', '\\' . $quote], $value);
184            // Put it all together!
185            $output .= "$normalizedKey = $quote$escapedValue$quote\n";
186        }
187        return trim($output) . "\n";
188    }
189
190    /**
191     * Extract comments from an array of lines read from a file.
192     *
193     * @param array $contents Contents to scan for comments.
194     *
195     * @return string
196     */
197    public function extractComments($contents)
198    {
199        $comments = '';
200        foreach ($contents as $line) {
201            if (str_starts_with(trim($line), ';')) {
202                $comments .= $line;
203            }
204        }
205        return $comments;
206    }
207
208    /**
209     * Check if the given filename matches the filter pattern
210     *
211     * @param string $filename Filename
212     * @param string $filter   Filter
213     *
214     * @return bool
215     */
216    protected function filenameMatchesFilter(string $filename, string $filter): bool
217    {
218        foreach (explode('|', $filter) as $pattern) {
219            if (fnmatch($pattern, $filename)) {
220                return true;
221            }
222        }
223        return false;
224    }
225
226    /**
227     * Check that the file to process is a valid language file.
228     *
229     * Throws an exception if unexpected content is detected.
230     *
231     * @param array  $lines    File contents
232     * @param string $filename Filename
233     *
234     * @return void
235     * @throws \Exception
236     */
237    protected function checkFileFormat(array $lines, string $filename): void
238    {
239        $lineNum = 0;
240        foreach ($lines as $line) {
241            ++$lineNum;
242            $line = trim($line);
243            if ('' === $line || strncmp($line, ';', 1) === 0) {
244                continue;
245            }
246            if (str_starts_with($line, '[') && str_ends_with($line, ']')) {
247                throw new \Exception(
248                    "Cannot normalize a file with sections; $filename line $lineNum"
249                    . " contains: $line"
250                );
251            }
252            if (strstr($line, '=') === false) {
253                throw new \Exception(
254                    "Equals sign not found in $filename line $lineNum$line"
255                );
256            }
257        }
258    }
259}