Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.40% covered (success)
97.40%
75 / 77
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImportLokaliseCommand
97.40% covered (success)
97.40%
75 / 77
71.43% covered (warning)
71.43%
5 / 7
19
0.00% covered (danger)
0.00%
0 / 1
 configure
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 collectSourceFiles
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 matchTargetFiles
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
6
 formatLokaliseLine
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeToDisk
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 importStrings
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 execute
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * Language command: ingest and normalise language files exported from Lokalise.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2023.
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  Console
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/development Wiki
28 */
29
30namespace VuFindConsole\Command\Language;
31
32use Symfony\Component\Console\Attribute\AsCommand;
33use Symfony\Component\Console\Input\InputArgument;
34use Symfony\Component\Console\Input\InputInterface;
35use Symfony\Component\Console\Output\OutputInterface;
36
37use function count;
38use function in_array;
39use function strlen;
40
41/**
42 * Language command: ingest and normalise language files exported from Lokalise.
43 *
44 * @category VuFind
45 * @package  Console
46 * @author   Demian Katz <demian.katz@villanova.edu>
47 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
48 * @link     https://vufind.org/wiki/development Wiki
49 */
50#[AsCommand(
51    name: 'language/importlokalise',
52    description: 'Lokalise file importer'
53)]
54class ImportLokaliseCommand extends AbstractCommand
55{
56    /**
57     * Configure the command.
58     *
59     * @return void
60     */
61    protected function configure()
62    {
63        $this
64            ->setHelp(
65                'Loads and normalizes language strings from Lokalise export files'
66            )->addArgument(
67                'source',
68                InputArgument::REQUIRED,
69                'source directory (containing language files from Lokalise)'
70            )->addArgument(
71                'target',
72                InputArgument::REQUIRED,
73                'the language directory to update with new strings'
74            );
75    }
76
77    /**
78     * Recurse through a directory collecting all .ini files.
79     *
80     * @param string $dir Directory to explore
81     *
82     * @return string[]
83     */
84    protected function collectSourceFiles(string $dir): array
85    {
86        $files = [];
87        $dirHandle = opendir($dir);
88        while ($file = readdir($dirHandle)) {
89            if (strlen(trim($file, '.')) === 0) {
90                continue;   // skip . and ..
91            }
92            $next = "$dir/$file";
93            if (is_dir($next)) {
94                $files = array_merge($files, $this->collectSourceFiles($next));
95            } elseif (str_ends_with($next, '.ini')) {
96                $files[] = $next;
97            }
98        }
99        closedir($dirHandle);
100        sort($files); // sort file list for consistent, predictable order of operations
101        return $files;
102    }
103
104    /**
105     * Given an array of files in $sourceDir, return an array of equivalent matching filenames
106     * in $targetDir.
107     *
108     * @param string   $sourceDir   Source directory
109     * @param string   $targetDir   Target directory
110     * @param string[] $sourceFiles Source files
111     *
112     * @return string[]
113     */
114    protected function matchTargetFiles(string $sourceDir, string $targetDir, array $sourceFiles): array
115    {
116        $targetFiles = [];
117        foreach ($sourceFiles as $sourceFile) {
118            $baseName = basename($sourceFile);
119            // Change file name from Lokalise format to VuFind format:
120            $normalizedFile = preg_replace(
121                '/' . preg_quote($baseName, '/') . '$/',
122                str_replace('_', '-', strtolower($baseName)),
123                $sourceFile
124            );
125            // Determine the equivalent filename in the target directory:
126            $targetFile = preg_replace(
127                '/^' . preg_quote($sourceDir, '/') . '/',
128                $targetDir,
129                $normalizedFile
130            );
131            // If the target file does not exist, check to see if removing the
132            // regional part of the code yields a match; otherwise, accept it as new
133            // unless the more general code is already defined separately in the
134            // source file list.
135            if (!file_exists($targetFile)) {
136                $parts = explode('-', $targetFile);
137                if (count($parts) > 1) {
138                    $lastPart = array_pop($parts);
139                    // If there's a slash in the last part, this means there's a hyphen
140                    // in a directory name somewhere. We should only process further if
141                    // the hyphen is in the FILENAME.
142                    if (!str_contains($lastPart, '/')) {
143                        $revisedTargetFile = implode('-', $parts) . '.ini';
144                        $matchingSourceFile = preg_replace(
145                            '/^' . preg_quote($targetDir, '/') . '/',
146                            $sourceDir,
147                            $revisedTargetFile
148                        );
149                        if (!in_array($matchingSourceFile, $sourceFiles)) {
150                            $targetFile = $revisedTargetFile;
151                        }
152                    }
153                }
154            }
155            $targetFiles[] = $targetFile;
156        }
157        return $targetFiles;
158    }
159
160    /**
161     * Format a single line from a Lokalise language file so it is ready for further
162     * processing by the language file normalizer.
163     *
164     * @param string $line Line to format
165     *
166     * @return string
167     */
168    protected function formatLokaliseLine(string $line): string
169    {
170        // Strip single quotes:
171        return preg_replace("/^(.* = )'(.*)'(\\n)?\$/", '$1$2$3', $line);
172    }
173
174    /**
175     * Write content to disk.
176     *
177     * @param string $file Filename
178     * @param string $text Text to write
179     *
180     * @return void
181     */
182    protected function writeToDisk(string $file, string $text): void
183    {
184        // We wrap the file write here for testing/extensibility purposes.
185        file_put_contents($file, $text);
186    }
187
188    /**
189     * Add new strings from $sourceFile to $targetFile.
190     *
191     * @param string $sourceFile New file from Lokalise
192     * @param string $targetFile Existing file in VuFind
193     *
194     * @return void
195     */
196    protected function importStrings(string $sourceFile, string $targetFile): void
197    {
198        $sourceStrings = array_map(
199            [$this, 'formatLokaliseLine'],
200            $this->normalizer->loadFileIntoArray($sourceFile)
201        );
202        $targetStrings = file_exists($targetFile) ? $this->normalizer->loadFileIntoArray($targetFile) : [];
203        $this->writeToDisk(
204            $targetFile,
205            $this->normalizer->normalizeArray(array_merge($targetStrings, $sourceStrings))
206        );
207    }
208
209    /**
210     * Run the command.
211     *
212     * @param InputInterface  $input  Input object
213     * @param OutputInterface $output Output object
214     *
215     * @return int 0 for success
216     */
217    protected function execute(InputInterface $input, OutputInterface $output)
218    {
219        $source = $input->getArgument('source');
220        $target = $input->getArgument('target');
221
222        if (!is_dir($source)) {
223            $output->writeln("{$source} does not exist or is not a directory.");
224            return 1;
225        }
226        if (!is_dir($target)) {
227            $output->writeln("{$target} does not exist or is not a directory.");
228            return 1;
229        }
230        $sourceFiles = $this->collectSourceFiles($source);
231        $targetFiles = $this->matchTargetFiles($source, $target, $sourceFiles);
232        array_map([$this, 'importStrings'], $sourceFiles, $targetFiles);
233        $output->writeln('Import complete.');
234        return 0;
235    }
236}