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

TranslateObjectsCommand::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 6
c 0
b 0
f 0
dl 0
loc 8
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
127
        return $parser;
128
    }
129
130
    /**
131
     * @inheritDoc
132
     */
133
    public function execute(Arguments $args, ConsoleIo $io): void
134
    {
135
        $this->setDryRun($args->getOption('dry-run'));
136
        $this->setIo($io);
137
138
        // parameter engine
139
        $engine = $args->getOption('engine') ?? 'deepl';
140
141
        // setup translator engine
142
        $cfg = Configure::read(sprintf('Translators.%s', $engine));
143
        if (empty($cfg)) {
144
            $this->getIo()->abort(sprintf('Translator %s not found', $engine));
145
        }
146
        $this->setTranslator($cfg);
147
148
        // parameters: lang from, lang to
149
        $from = $args->getOption('from');
150
        $to = $args->getOption('to');
151
        $this->limit = $args->getOption('limit') ?? null;
152
        $this->getIo()->out(
153
            sprintf(
154
                'Translating objects from %s to %s [dry-run %s / limit %s]',
155
                $from,
156
                $to,
157
                $this->dryRun ? 'yes' : 'no',
158
                $this->limit ? sprintf('limit %s', $this->limit) : 'unlimited'
159
            )
160
        );
161
        $to = $this->langsMap[$to];
162
        if ($this->getIo()->ask('Do you want to continue [Y/n]?', 'n') !== 'Y') {
163
            $this->getIo()->abort('Bye');
164
        }
165
        $this->ok = $this->error = 0;
166
        $this->defaultStatus = $args->getOption('status');
167
        $this->processObjects($from, $to);
168
        $this->getIo()->out($this->results());
169
        $this->getIo()->out('Done');
170
    }
171
172
    /**
173
     * Set console io.
174
     *
175
     * @param \Cake\Console\ConsoleIo $io The console io
176
     * @return void
177
     */
178
    public function setIo(ConsoleIo $io): void
179
    {
180
        $this->io = $io;
181
    }
182
183
    /**
184
     * Get console io.
185
     *
186
     * @return \Cake\Console\ConsoleIo
187
     */
188
    public function getIo(): ConsoleIo
189
    {
190
        return $this->io;
191
    }
192
193
    /**
194
     * Set dry run.
195
     *
196
     * @param bool $dryRun The dry run flag
197
     * @return void
198
     */
199
    public function setDryRun(bool $dryRun): void
200
    {
201
        $this->dryRun = $dryRun;
202
    }
203
204
    /**
205
     * Get dry run.
206
     *
207
     * @return bool
208
     */
209
    public function getDryRun(): bool
210
    {
211
        return $this->dryRun;
212
    }
213
214
    /**
215
     * Get results.
216
     *
217
     * @return string
218
     */
219
    public function results(): string
220
    {
221
        return sprintf('Processed %d objects (%d errors)', $this->ok + $this->error, $this->error);
222
    }
223
224
    /**
225
     * Set translator engine.
226
     *
227
     * @param array $cfg The translator configuration
228
     * @return void
229
     */
230
    public function setTranslator(array $cfg): void
231
    {
232
        $class = (string)Hash::get($cfg, 'class');
233
        $options = (array)Hash::get($cfg, 'options');
234
        $this->translator = new $class();
235
        $this->translator->setup($options);
236
    }
237
238
    /**
239
     * Process objects to translate.
240
     *
241
     * @param string $from The language to translate from
242
     * @param string $to The language to translate to
243
     * @return void
244
     */
245
    public function processObjects(string $from, string $to): void
246
    {
247
        $conditions = [];
248
        foreach ($this->objectsIterator($conditions, $from, $to) as $object) {
249
            if ($this->limit !== null && ($this->ok + $this->error >= $this->limit)) {
250
                break;
251
            }
252
            $this->processObject($object, $from, $to);
253
        }
254
    }
255
256
    /**
257
     * Process single object.
258
     *
259
     * @param \BEdita\Core\Model\Entity\ObjectEntity $object The object to translate
260
     * @param string $from The language to translate from
261
     * @param string $to The language to translate to
262
     * @return void
263
     */
264
    public function processObject(ObjectEntity $object, string $from, string $to): void
265
    {
266
        try {
267
            $this->getIo()->verbose(sprintf('Translating object %s', $object->id));
268
            if (!$this->dryRun) {
269
                $this->translate($object, $from, $to);
270
            }
271
            $this->getIo()->verbose(sprintf('Translated object %s', $object->id));
272
            $this->ok++;
273
        } catch (\Exception $e) {
274
            $this->getIo()->error(sprintf('Error translating object %s: %s', $object->id, $e->getMessage()));
275
            $this->error++;
276
        }
277
    }
278
279
    /**
280
     * Get objects as iterable.
281
     *
282
     * @param array $conditions The conditions to filter objects.
283
     * @param string $lang The language to use to find objects.
284
     * @param string $to The language to translate objects to.
285
     * @return iterable
286
     */
287
    public function objectsIterator(array $conditions, string $lang, string $to): iterable
288
    {
289
        $table = $this->fetchTable('objects');
290
        $conditions = array_merge(
291
            $conditions,
292
            [
293
                $table->aliasField('deleted') => 0,
294
                $table->aliasField('lang') => $lang,
295
            ]
296
        );
297
        $query = $table->find('all')
298
            ->where($conditions)
299
            ->notMatching('Translations', function ($q) use ($to) {
300
                return $q->where(['Translations.lang' => $to]);
301
            })
302
            ->orderAsc($table->aliasField('id'))
303
            ->limit(500);
304
        $lastId = 0;
305
        while (true) {
306
            $q = clone $query;
307
            $q = $q->where(fn (QueryExpression $exp): QueryExpression => $exp->gt($table->aliasField('id'), $lastId));
308
            $results = $q->all();
309
            if ($results->isEmpty()) {
310
                break;
311
            }
312
313
            foreach ($results as $entity) {
314
                $lastId = $entity->id;
315
316
                yield $entity;
317
            }
318
        }
319
    }
320
321
    /**
322
     * Translate object translatable fields and store translation in translations table.
323
     *
324
     * @param \BEdita\Core\Model\Entity\ObjectEntity $object The object to translate
325
     * @param string $from The language to translate from
326
     * @param string $to The language to translate to
327
     * @return void
328
     */
329
    public function translate($object, $from, $to): void
330
    {
331
        $object = $this->fetchTable($object->type)->find()->where(['id' => $object->id])->firstOrFail();
332
        $translatableFields = $this->translatableFields($object->type);
333
        if (empty($translatableFields)) {
334
            return;
335
        }
336
        $translatedFields = [];
337
        $fields = $values = [];
338
        foreach ($translatableFields as $field) {
339
            if (empty($object->get($field))) {
340
                continue;
341
            }
342
            $fields[] = $field;
343
            $values[] = $object->get($field);
344
        }
345
        $tr = $this->multiTranslation($values, $from, $to);
346
        foreach ($tr as $i => $t) {
347
            $translatedFields[$fields[$i]] = $t;
348
        }
349
        $translation = [
350
            'object_id' => $object->id,
351
            'lang' => array_flip($this->langsMap)[$to],
352
            'translated_fields' => json_encode($translatedFields),
353
            'status' => $this->defaultStatus,
354
        ];
355
        $table = $this->fetchTable('Translations');
356
        $entity = $table->newEntity($translation);
357
        $entity->set('translated_fields', json_decode($translation['translated_fields'], true));
358
        $entity->set('status', $this->defaultStatus);
359
        LoggedUser::setUserAdmin();
360
        $table->saveOrFail($entity);
361
    }
362
363
    /**
364
     * Get translatable fields by object type.
365
     *
366
     * @param string $type The object type
367
     * @return array
368
     */
369
    public function translatableFields(string $type): array
370
    {
371
        if (array_key_exists($type, $this->translatableFields)) {
372
            return $this->translatableFields[$type];
373
        }
374
        $schema = JsonSchema::typeSchema($type);
375
        $this->translatableFields[$type] = (array)Hash::get($schema, 'translatable');
376
377
        return $this->translatableFields[$type];
378
    }
379
380
    /**
381
     * Translate a single text.
382
     *
383
     * @param mixed $text The text to translate
384
     * @param string $from The language to translate from
385
     * @param string $to The language to translate to
386
     * @return string
387
     */
388
    public function singleTranslation($text, string $from, string $to): string
389
    {
390
        $response = $this->translator->translate([$text], $from, $to);
391
        $response = json_decode($response, true);
392
393
        return (string)Hash::get($response, 'translation.0');
394
    }
395
396
    /**
397
     * Translate multiple texts.
398
     *
399
     * @param array $texts The texts to translate
400
     * @param string $from The language to translate from
401
     * @param string $to The language to translate to
402
     * @return array
403
     */
404
    public function multiTranslation($texts, string $from, string $to): array
405
    {
406
        $response = $this->translator->translate($texts, $from, $to);
407
        $response = json_decode($response, true);
408
409
        return (array)Hash::get($response, 'translation');
410
    }
411
}
412