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

TranslateObjectsCommand::processObject()   A

Complexity

Conditions 3
Paths 6

Size

Total Lines 12
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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