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