Passed
Pull Request — main (#9)
by Dante
01:11
created

TranslateObjectsCommand::getDryRun()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 0
1
<?php
2
declare(strict_types=1);
3
4
/**
5
 * BEdita, API-first content management framework
6
 * Copyright 2024 Atlas Srl, Chialab Srl
7
 *
8
 * This file is part of BEdita: you can redistribute it and/or modify
9
 * it under the terms of the GNU Lesser General Public License as published
10
 * by the Free Software Foundation, either version 3 of the License, or
11
 * (at your option) any later version.
12
 *
13
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
14
 */
15
namespace BEdita\ImportTools\Command;
16
17
use BEdita\Core\Model\Entity\ObjectEntity;
18
use BEdita\Core\Utility\JsonSchema;
19
use BEdita\Core\Utility\LoggedUser;
20
use Cake\Command\Command;
21
use Cake\Console\Arguments;
22
use Cake\Console\ConsoleIo;
23
use Cake\Console\ConsoleOptionParser;
24
use Cake\Core\Configure;
25
use Cake\Core\InstanceConfigTrait;
26
use Cake\Database\Expression\QueryExpression;
27
use Cake\Utility\Hash;
28
29
/**
30
 * TranslateObjects command.
31
 * Translate objects from a language to another using a translator engine.
32
 * The translator engine is defined in the configuration.
33
 * The configuration must contain the translator engine class and options
34
 * I.e.:
35
 * 'Translators' => [
36
 *   'deepl' => [
37
 *      'name' => 'DeepL',
38
 *      'class' => '\BEdita\I18n\Deepl\Core\Translator',
39
 *      'options' => [
40
 *        'auth_key' => '************',
41
 *      ],
42
 *   ],
43
 * ],
44
 */
45
class TranslateObjectsCommand extends Command
46
{
47
    use InstanceConfigTrait;
48
49
    protected $_defaultConfig = [
50
        'langsMap' => [
51
            'en' => 'en-US',
52
            'it' => 'it',
53
            'de' => 'de',
54
            'es' => 'es',
55
            'fr' => 'fr',
56
            'pt' => 'pt-PT',
57
        ],
58
        'status' => 'draft',
59
        'dryRun' => false,
60
    ];
61
    protected $ok;
62
    protected $error;
63
    protected $io;
64
    protected $dryRun;
65
    protected $defaultStatus;
66
    protected $translatableFields = [];
67
    protected $translator;
68
    protected $langsMap;
69
    protected $limit;
70
71
    /**
72
     * @inheritDoc
73
     */
74
    public function __construct()
75
    {
76
        parent::__construct();
77
        $cfg = (array)Configure::read('TranslateObjects');
78
        $cfg = array_merge($this->_defaultConfig, $cfg);
79
        $this->defaultStatus = (string)Hash::get($cfg, 'status');
80
        $this->setDryRun(Hash::get($cfg, 'dryRun') === 1);
81
        $this->langsMap = (array)Hash::get($cfg, 'langsMap');
82
    }
83
84
    /**
85
     * @inheritDoc
86
     */
87
    public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
88
    {
89
        $langs = array_keys($this->langsMap);
90
        $parser = parent::buildOptionParser($parser);
91
        $parser->addOption('from', [
92
            'short' => 'f',
93
            'help' => 'Language to translate from',
94
            'required' => true,
95
            'choices' => $langs,
96
        ]);
97
        $parser->addOption('to', [
98
            'short' => 't',
99
            'help' => 'Language to translate to',
100
            'required' => true,
101
            'choices' => $langs,
102
        ]);
103
        $parser->addOption('engine', [
104
            'short' => 'e',
105
            'help' => 'Translator engine',
106
            'default' => 'deepl',
107
            'required' => true,
108
            'choices' => ['aws', 'deepl', 'google', 'microsoft'],
109
        ]);
110
        $parser->addOption('status', [
111
            'short' => 's',
112
            'help' => 'Status for new translations',
113
            'choices' => ['draft', 'on', 'off'],
114
            'default' => 'draft',
115
        ]);
116
        $parser->addOption('dry-run', [
117
            'short' => 'd',
118
            'help' => 'Dry run',
119
            'boolean' => true,
120
            'default' => false,
121
        ]);
122
        $parser->addOption('limit', [
123
            'short' => 'l',
124
            'help' => 'Limit number of objects to translate',
125
        ]);
126
        $parser->addOption('object-type', [
127
            'short' => 'o',
128
            'help' => 'Object type to translate',
129
        ]);
130
131
        return $parser;
132
    }
133
134
    /**
135
     * @inheritDoc
136
     */
137
    public function execute(Arguments $args, ConsoleIo $io): void
138
    {
139
        $this->setDryRun($args->getOption('dry-run'));
140
        $this->setIo($io);
141
142
        // parameter engine
143
        $engine = $args->getOption('engine') ?? 'deepl';
144
145
        // setup translator engine
146
        $cfg = Configure::read(sprintf('Translators.%s', $engine));
147
        if (empty($cfg)) {
148
            $this->getIo()->abort(sprintf('Translator %s not found', $engine));
149
        }
150
        $this->setTranslator($cfg);
151
152
        // parameters: lang from, lang to
153
        $from = $args->getOption('from');
154
        $to = $args->getOption('to');
155
        $this->limit = $args->getOption('limit') ?? null;
156
        $this->type = $args->getOption('object-type') ?? null;
157
        $this->getIo()->out(
158
            sprintf(
159
                'Translating objects from %s to %s [dry-run %s / limit %s / %s]',
160
                $from,
161
                $to,
162
                $this->dryRun ? 'yes' : 'no',
163
                $this->limit ? sprintf('limit %s', $this->limit) : 'unlimited',
164
                $this->type ? sprintf('type %s', $this->type) : 'all types'
165
            )
166
        );
167
        $to = $this->langsMap[$to];
168
        if ($this->getIo()->ask('Do you want to continue [Y/n]?', 'n') !== 'Y') {
169
            $this->getIo()->abort('Bye');
170
        }
171
        $this->ok = $this->error = 0;
172
        $this->defaultStatus = $args->getOption('status');
173
        $this->processObjects($from, $to);
174
        $this->getIo()->out($this->results());
175
        $this->getIo()->out('Done');
176
    }
177
178
    /**
179
     * Set console io.
180
     *
181
     * @param \Cake\Console\ConsoleIo $io The console io
182
     * @return void
183
     */
184
    public function setIo(ConsoleIo $io): void
185
    {
186
        $this->io = $io;
187
    }
188
189
    /**
190
     * Get console io.
191
     *
192
     * @return \Cake\Console\ConsoleIo
193
     */
194
    public function getIo(): ConsoleIo
195
    {
196
        return $this->io;
197
    }
198
199
    /**
200
     * Set dry run.
201
     *
202
     * @param bool $dryRun The dry run flag
203
     * @return void
204
     */
205
    public function setDryRun(bool $dryRun): void
206
    {
207
        $this->dryRun = $dryRun;
208
    }
209
210
    /**
211
     * Get dry run.
212
     *
213
     * @return bool
214
     */
215
    public function getDryRun(): bool
216
    {
217
        return $this->dryRun;
218
    }
219
220
    /**
221
     * Get results.
222
     *
223
     * @return string
224
     */
225
    public function results(): string
226
    {
227
        return sprintf('Processed %d objects (%d errors)', $this->ok + $this->error, $this->error);
228
    }
229
230
    /**
231
     * Set translator engine.
232
     *
233
     * @param array $cfg The translator configuration
234
     * @return void
235
     */
236
    public function setTranslator(array $cfg): void
237
    {
238
        $class = (string)Hash::get($cfg, 'class');
239
        $options = (array)Hash::get($cfg, 'options');
240
        $this->translator = new $class();
241
        $this->translator->setup($options);
242
    }
243
244
    /**
245
     * Process objects to translate.
246
     *
247
     * @param string $from The language to translate from
248
     * @param string $to The language to translate to
249
     * @return void
250
     */
251
    public function processObjects(string $from, string $to): void
252
    {
253
        $conditions = [];
254
        foreach ($this->objectsIterator($conditions, $from, $to) as $object) {
255
            if ($this->limit !== null && ($this->ok + $this->error >= $this->limit)) {
256
                break;
257
            }
258
            $this->processObject($object, $from, $to);
259
        }
260
    }
261
262
    /**
263
     * Process single object.
264
     *
265
     * @param \BEdita\Core\Model\Entity\ObjectEntity $object The object to translate
266
     * @param string $from The language to translate from
267
     * @param string $to The language to translate to
268
     * @return void
269
     */
270
    public function processObject(ObjectEntity $object, string $from, string $to): void
271
    {
272
        try {
273
            $this->getIo()->verbose(sprintf('Translating object %s', $object->id));
274
            if (!$this->dryRun) {
275
                $this->translate($object, $from, $to);
276
            }
277
            $this->getIo()->verbose(sprintf('Translated object %s', $object->id));
278
            $this->ok++;
279
        } catch (\Exception $e) {
280
            $this->getIo()->error(sprintf('Error translating object %s: %s', $object->id, $e->getMessage()));
281
            $this->error++;
282
        }
283
    }
284
285
    /**
286
     * Get objects as iterable.
287
     *
288
     * @param array $conditions The conditions to filter objects.
289
     * @param string $lang The language to use to find objects.
290
     * @param string $to The language to translate objects to.
291
     * @return iterable
292
     */
293
    public function objectsIterator(array $conditions, string $lang, string $to): iterable
294
    {
295
        $table = $this->fetchTable('objects');
296
        if ($this->type !== null) {
297
            $conditions[$table->aliasField('object_type_id')] = $table->objectType($this->type)->id;
298
        }
299
        $conditions = array_merge(
300
            $conditions,
301
            [
302
                $table->aliasField('deleted') => 0,
303
                $table->aliasField('lang') => $lang,
304
            ]
305
        );
306
        $query = $table->find('all')
307
            ->where($conditions)
308
            ->notMatching('Translations', function ($q) use ($to) {
309
                return $q->where(['Translations.lang' => $to]);
310
            })
311
            ->orderAsc($table->aliasField('id'))
312
            ->limit(500);
313
        $lastId = 0;
314
        while (true) {
315
            $q = clone $query;
316
            $q = $q->where(fn (QueryExpression $exp): QueryExpression => $exp->gt($table->aliasField('id'), $lastId));
317
            $results = $q->all();
318
            if ($results->isEmpty()) {
319
                break;
320
            }
321
322
            foreach ($results as $entity) {
323
                $lastId = $entity->id;
324
325
                yield $entity;
326
            }
327
        }
328
    }
329
330
    /**
331
     * Translate object translatable fields and store translation in translations table.
332
     *
333
     * @param \BEdita\Core\Model\Entity\ObjectEntity $object The object to translate
334
     * @param string $from The language to translate from
335
     * @param string $to The language to translate to
336
     * @return void
337
     */
338
    public function translate($object, $from, $to): void
339
    {
340
        $id = $object->id;
341
        $type = $object->type;
342
        $object = $this->fetchTable($type)->find()->where(compact('id'))->firstOrFail();
343
        $translatableFields = $this->translatableFields($type);
344
        if (empty($translatableFields)) {
345
            return;
346
        }
347
        $translatedFields = [];
348
        $fields = $values = $jsonFields = $jsonValues = [];
349
        foreach ($translatableFields as $field) {
350
            if (empty($object->get($field))) {
351
                continue;
352
            }
353
            $val = $object->get($field);
354
            if (is_array($val)) {
355
                $jsonFields[] = $field;
356
                $jsonValues[] = json_encode($val);
357
            } else {
358
                $fields[] = $field;
359
                $values[] = $val;
360
            }
361
        }
362
        if (empty($fields) && empty($jsonFields)) {
363
            return;
364
        }
365
        $tr = $this->multiTranslation($values, $from, $to);
366
        foreach ($tr as $i => $t) {
367
            $translatedFields[$fields[$i]] = $t;
368
        }
369
        $tr = $this->multiTranslation($jsonValues, $from, $to);
370
        foreach ($tr as $i => $t) {
371
            $translatedFields[$jsonFields[$i]] = json_decode($t, true);
372
        }
373
        $translation = [
374
            'object_id' => $id,
375
            'lang' => array_flip($this->langsMap)[$to],
376
            'translated_fields' => json_encode($translatedFields),
377
            'status' => $this->defaultStatus,
378
        ];
379
        $table = $this->fetchTable('Translations');
380
        $entity = $table->newEntity($translation);
381
        $entity->set('translated_fields', json_decode($translation['translated_fields'], true));
382
        $entity->set('status', $this->defaultStatus);
383
        LoggedUser::setUserAdmin();
384
        $table->saveOrFail($entity);
385
    }
386
387
    /**
388
     * Get translatable fields by object type.
389
     *
390
     * @param string $type The object type
391
     * @return array
392
     */
393
    public function translatableFields(string $type): array
394
    {
395
        if (array_key_exists($type, $this->translatableFields)) {
396
            return $this->translatableFields[$type];
397
        }
398
        $schema = JsonSchema::typeSchema($type);
399
        $this->translatableFields[$type] = (array)Hash::get($schema, 'translatable');
400
401
        return $this->translatableFields[$type];
402
    }
403
404
    /**
405
     * Translate a single text.
406
     *
407
     * @param mixed $text The text to translate
408
     * @param string $from The language to translate from
409
     * @param string $to The language to translate to
410
     * @return string
411
     */
412
    public function singleTranslation($text, string $from, string $to): string
413
    {
414
        $response = $this->translator->translate([$text], $from, $to);
415
        $response = json_decode($response, true);
416
417
        return (string)Hash::get($response, 'translation.0');
418
    }
419
420
    /**
421
     * Translate multiple texts.
422
     *
423
     * @param array $texts The texts to translate
424
     * @param string $from The language to translate from
425
     * @param string $to The language to translate to
426
     * @return array
427
     */
428
    public function multiTranslation($texts, string $from, string $to): array
429
    {
430
        $response = $this->translator->translate($texts, $from, $to);
431
        $response = json_decode($response, true);
432
433
        return (array)Hash::get($response, 'translation');
434
    }
435
}
436