TranslateObjectsCommand   A
last analyzed

Complexity

Total Complexity 39

Size/Duplication

Total Lines 391
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 39
eloc 184
c 1
b 0
f 0
dl 0
loc 391
rs 9.28

16 Methods

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