Completed
Push — master ( a25fa3...af58be )
by Stefano
14s
created

GettextShell::ttagExtract()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 28
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 14
nc 3
nop 0
dl 0
loc 28
rs 9.7998
c 0
b 0
f 0
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2018 ChannelWeb Srl, Chialab Srl
5
 *
6
 * This file is part of BEdita: you can redistribute it and/or modify
7
 * it under the terms of the GNU Lesser General Public License as published
8
 * by the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 *
11
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
12
 */
13
14
namespace BEdita\I18n\Shell;
15
16
use Cake\Console\Shell;
17
use Cake\Filesystem\File;
18
use Cake\Filesystem\Folder;
19
20
/**
21
 * Gettext shell
22
 */
23
class GettextShell extends Shell
24
{
25
    /**
26
     * Get the option parser for this shell.
27
     *
28
     * @return \Cake\Console\ConsoleOptionParser
29
     */
30
    public function getOptionParser()
31
    {
32
        $parser = parent::getOptionParser();
33
        $parser->addSubcommand('update', [
34
            'help' => 'Update po and pot files',
35
            'parser' => [
36
                'description' => [
37
                    'Create or update i18n files',
38
                    '`cake gettext update -app <app path>` will update po/pot file for the app',
39
                    '`cake gettext update -plugin <plugin path>` will update po/pot file for the plugin',
40
                ],
41
                'options' => [
42
                    'app' => [
43
                        'help' => 'The app path, for i18n update.',
44
                        'short' => 'a',
45
                        'required' => false,
46
                    ],
47
                    'plugin' => [
48
                        'help' => 'The plugin path, for i18n update.',
49
                        'short' => 'p',
50
                        'required' => false,
51
                    ],
52
                ]
53
            ]
54
        ]);
55
56
        return $parser;
57
    }
58
59
    /**
60
     * The Po results
61
     *
62
     * @var array
63
     */
64
    protected $poResult = [];
65
66
    /**
67
     * The template paths
68
     *
69
     * @var array
70
     */
71
    protected $templatePaths = [];
72
73
    /**
74
     * The locale path
75
     *
76
     * @var string
77
     */
78
    protected $localePath = null;
79
80
    /**
81
     * PO file name
82
     *
83
     * @var string
84
     */
85
    protected $poName = 'default.po';
86
87
    /**
88
     * Update gettext po files
89
     *
90
     * @return void
91
     */
92
    public function update() : void
93
    {
94
        $resCmd = [];
95
        exec('which msgmerge 2>&1', $resCmd);
96
        if (empty($resCmd[0])) {
97
            $this->out('ERROR: msgmerge not available. Please install gettext utilities.');
98
99
            return;
100
        }
101
102
        $this->out('Updating .pot and .po files...');
103
104
        $this->setupPaths();
105
        foreach ($this->templatePaths as $path) {
106
            $this->out(sprintf('Search in: %s', $path));
107
            $this->parseDir($path);
108
        }
109
110
        $this->out('Creating master .pot file');
111
        $this->writeMasterPot();
112
        $this->ttagExtract();
113
114
        $this->hr();
115
        $this->out('Merging master .pot with current .po files');
116
        $this->hr();
117
118
        $this->writePoFiles();
119
120
        $this->out('Done');
121
    }
122
123
    /**
124
     * Setup template paths and locale path
125
     *
126
     * @return void
127
     */
128
    private function setupPaths() : void
129
    {
130
        $basePath = getcwd();
131
        if (isset($this->params['app'])) {
132
            $f = new Folder($this->params['app']);
133
            $basePath = $f->path;
134
        } elseif (isset($this->params['plugin'])) {
135
            $f = new Folder(sprintf('%s/plugins/%s', getcwd(), $this->params['plugin']));
136
            $basePath = $f->path;
137
            $this->poName = $this->params['plugin'] . ".po";
138
        }
139
140
        $this->templatePaths = [
141
            $basePath . '/src',
142
            $basePath . '/config',
143
        ];
144
        $this->localePath = $basePath . '/src/Locale';
145
    }
146
147
    /**
148
     * Write `master.pot` file
149
     *
150
     * @return void
151
     */
152
    private function writeMasterPot() : void
153
    {
154
        $potFilename = sprintf('%s/master.pot', $this->localePath);
155
        $this->out(sprintf('Writing new .pot file: %s', $potFilename));
156
        $pot = new File($potFilename, true);
157
        $pot->write($this->header('pot'));
158
        sort($this->poResult);
159
        foreach ($this->poResult as $res) {
160
            if (!empty($res)) {
161
                $pot->write(sprintf('%smsgid "%s"%smsgstr ""%s', "\n", $res, "\n", "\n"));
162
            }
163
        }
164
        $pot->close();
165
    }
166
167
    /**
168
     * Write `.po` files
169
     *
170
     * @return void
171
     */
172
    private function writePoFiles() : void
173
    {
174
        $header = $this->header('po');
175
        $potFilename = sprintf('%s/master.pot', $this->localePath);
176
        $folder = new Folder($this->localePath);
177
        $ls = $folder->read();
178
        foreach ($ls[0] as $loc) {
179
            if ($loc[0] != '.') { // only "regular" dirs...
180
                $this->out(sprintf('Language: %s', $loc));
181
                $poFile = sprintf('%s/%s/%s', $this->localePath, $loc, $this->poName);
182
                if (!file_exists($poFile)) {
183
                    $newPoFile = new File($poFile, true);
184
                    $newPoFile->write($header);
185
                    $newPoFile->close();
186
                }
187
                $this->out(sprintf('Merging %s', $poFile));
188
                $mergeCmd = sprintf('msgmerge --backup=off -N -U %s %s', $poFile, $potFilename);
189
                exec($mergeCmd);
190
                $this->analyzePoFile($poFile);
191
                $this->hr();
192
            }
193
        }
194
    }
195
196
    /**
197
     * Header lines for po/pot file
198
     *
199
     * @param string $type The file type (can be 'po', 'pot')
200
     * @return string
201
     */
202
    private function header(string $type = 'po') : string
203
    {
204
        $result = sprintf('msgid ""%smsgstr ""%s', "\n", "\n");
205
        $contents = [
206
            'po' => [
207
                'Project-Id-Version' => 'BEdita 4',
208
                'POT-Creation-Date' => date("Y-m-d H:i:s"),
209
                'PO-Revision-Date' => '',
210
                'Last-Translator' => '',
211
                'Language-Team' => 'BEdita I18N & I10N Team',
212
                'Language' => '',
213
                'MIME-Version' => '1.0',
214
                'Content-Transfer-Encoding' => '8bit',
215
                'Plural-Forms' => 'nplurals=2; plural=(n != 1);',
216
                'Content-Type' => 'text/plain; charset=utf-8',
217
            ],
218
            'pot' => [
219
                'Project-Id-Version' => 'BEdita 4',
220
                'POT-Creation-Date' => date("Y-m-d H:i:s"),
221
                'MIME-Version' => '1.0',
222
                'Content-Transfer-Encoding' => '8bit',
223
                'Language-Team' => 'BEdita I18N & I10N Team',
224
                'Plural-Forms' => 'nplurals=2; plural=(n != 1);',
225
                'Content-Type' => 'text/plain; charset=utf-8',
226
            ],
227
        ];
228
        foreach ($contents[$type] as $k => $v) {
229
            $result .= sprintf('"%s: %s \n"', $k, $v) . "\n";
230
        }
231
232
        return $result;
233
    }
234
235
    /**
236
     * Analyze po file and translate it
237
     *
238
     * @param string $filename The po file name
239
     * @return void
240
     */
241
    private function analyzePoFile($filename) : void
242
    {
243
        $lines = file($filename);
244
        $numItems = $numNotTranslated = 0;
245
        foreach ($lines as $k => $l) {
246
            if (strpos($l, 'msgid "') === 0) {
247
                $numItems++;
248
            }
249
            if (strpos($l, 'msgstr ""') === 0) {
250
                if (!isset($lines[$k + 1])) {
251
                    $numNotTranslated++;
252
                } elseif (strpos($lines[$k + 1], '"') !== 0) {
253
                    $numNotTranslated++;
254
                }
255
            }
256
        }
257
        $translated = $numItems - $numNotTranslated;
258
        $percent = 0;
259
        if ($numItems > 0) {
260
            $percent = number_format(($translated * 100.) / $numItems, 1);
261
        }
262
        $this->out(sprintf('Translated %s of items - %s %', $translated, $numItems, $percent));
263
    }
264
265
    /**
266
     * "fix" string - strip slashes, escape and convert new lines to \n
267
     *
268
     * @param string $str The string
269
     * @return string The new string
270
     */
271
    private function fixString($str) : string
272
    {
273
        $str = stripslashes($str);
274
        $str = str_replace('"', '\"', $str);
275
        $str = str_replace("\n", '\n', $str);
276
277
        return $str;
278
    }
279
280
    /**
281
     * Parse file and rips gettext strings
282
     *
283
     * @param string $file The file name
284
     * @param string $extension The file extension
285
     * @return void
286
     */
287
    private function parseFile($file, $extension)
288
    {
289
        $content = file_get_contents($file);
290
        if (empty($content)) {
291
            return;
292
        }
293
294
        if ($extension === 'twig' || $extension === 'php') {
295
            $this->parseContent($content);
296
        }
297
    }
298
299
    /**
300
     * Parse file content and put i18n data in poResult array
301
     *
302
     * @param string $content The file content
303
     * @return void
304
     */
305
    private function parseContent($content) : void
306
    {
307
        $p = preg_quote("(");
308
        $q1 = preg_quote("'");
309
        $q2 = preg_quote('"');
310
311
        // looks for __("text to translate",true)
312
        // or __('text to translate',true), result in matches[1] or in matches[2]
313
        $rgxp = "/__\s*{$p}\s*{$q2}" . "([^{$q2}]*)" . "{$q2}" . "|" . "__\s*{$p}\s*{$q1}" . "([^{$q1}]*)" . "{$q1}/";
314
        $matches = [];
315
        preg_match_all($rgxp, $content, $matches);
316
317
        $limit = count($matches[0]);
318
        for ($i = 0; $i < $limit; $i++) {
319
            $item = $this->fixString($matches[1][$i]);
320
            if (empty($item)) {
321
                $item = $this->fixString($matches[2][$i]);
322
            }
323
            if (!in_array($item, $this->poResult)) {
324
                $this->poResult[] = $item;
325
            }
326
        }
327
    }
328
329
    /**
330
     * Parse a directory
331
     *
332
     * @param string $dir The directory
333
     * @return void
334
     */
335
    private function parseDir($dir) : void
336
    {
337
        $folder = new Folder($dir);
338
        $tree = $folder->tree($dir, false);
339
        foreach ($tree as $files) {
340
            foreach ($files as $file) {
341
                if (!is_dir($file)) {
342
                    $f = new File($file);
343
                    $info = $f->info();
344
                    if (isset($info['extension'])) {
345
                        $this->parseFile($file, $info['extension']);
346
                    }
347
                }
348
            }
349
        }
350
    }
351
352
    /**
353
     * Extract translations from javascript files using ttag, if available.
354
     *
355
     * @return void
356
     */
357
    private function ttagExtract() : void
358
    {
359
        // check ttag command exists
360
        $ttag = 'node_modules/ttag-cli/bin/ttag';
361
        if (!file_exists($ttag)) {
362
            $this->out(sprintf('Skip javascript parsing - %s command not found', $ttag));
363
364
            return;
365
        }
366
        // check template folder exists
367
        $appDir = 'src/Template';
368
        if (!file_exists($appDir)) {
369
            $this->out(sprintf('Skip javascript parsing - %s folder not found', $appDir));
370
371
            return;
372
        }
373
374
        // do extract translation strings from js files using ttag
375
        $this->out('Extracting translation string from javascript files using ttag');
376
        $masterJs = sprintf('%s/master-js.pot', $this->localePath);
377
        exec(sprintf('%s extract --o %s --l en %s', $ttag, $masterJs, $appDir));
378
379
        // merge master-js.pot and master.pot
380
        $master = sprintf('%s/master.pot', $this->localePath);
381
        exec(sprintf('msgcat --use-first %s %s -o %s', $master, $masterJs, $master));
382
383
        // remove master-js.pot
384
        unlink($masterJs);
385
    }
386
}
387