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

TranslateObjectsCommand::buildOptionParser()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 37
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Importance

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