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

TranslateObjectsCommand::execute()   B

Complexity

Conditions 7
Paths 28

Size

Total Lines 44
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 30
c 1
b 0
f 0
dl 0
loc 44
rs 8.5066
cc 7
nc 28
nop 2
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 $dryRun;
48
    protected $defaultStatus;
49
    protected $translatableFields = [];
50
    protected $translator;
51
    protected $langsMap = [
52
        'en' => 'en-US',
53
        'it' => 'it-IT',
54
        'de' => 'de-DE',
55
    ];
56
57
    /**
58
     * @inheritDoc
59
     */
60
    public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
61
    {
62
        $parser = parent::buildOptionParser($parser);
63
        $parser->addOption('from', [
64
            'short' => 'f',
65
            'help' => 'Language to translate from',
66
            'required' => true,
67
            'choices' => ['en', 'it', 'de'],
68
        ]);
69
        $parser->addOption('to', [
70
            'short' => 't',
71
            'help' => 'Language to translate to',
72
            'required' => true,
73
            'choices' => ['en', 'it', 'de'],
74
        ]);
75
        $parser->addOption('engine', [
76
            'short' => 'e',
77
            'help' => 'Translator engine',
78
            'default' => 'deepl',
79
            'required' => true,
80
            'choices' => ['aws', 'deepl', 'google', 'microsoft'],
81
        ]);
82
        $parser->addOption('status', [
83
            'short' => 's',
84
            'help' => 'Status for new translations',
85
            'choices' => ['draft', 'on', 'off'],
86
            'default' => 'draft',
87
        ]);
88
        $parser->addOption('dry-run', [
89
            'short' => 'd',
90
            'help' => 'Dry run',
91
            'boolean' => true,
92
            'default' => false,
93
        ]);
94
95
        return $parser;
96
    }
97
98
    /**
99
     * @inheritDoc
100
     */
101
    public function execute(Arguments $args, ConsoleIo $io)
102
    {
103
        $this->dryRun = $args->getOption('dry-run');
104
105
        // parameter engine
106
        $engine = $args->getOption('engine') ?? 'deepl';
107
108
        // setup translator engine
109
        $cfg = Configure::read(sprintf('Translators.%s', $engine));
110
        if (empty($cfg)) {
111
            $io->abort(sprintf('Translator %s not found', $engine));
112
        }
113
        $class = (string)Hash::get($cfg, 'class');
114
        $options = (array)Hash::get($cfg, 'options');
115
        $this->translator = new $class();
116
        $this->translator->setup($options);
117
118
        // parameters: lang from, lang to
119
        $from = $args->getOption('from');
120
        $to = $args->getOption('to');
121
        $to = $this->langsMap['en'];
122
        $io->out(sprintf('Translating objects from %s to %s [dry-run %s]', $from, $to, $this->dryRun ? 'yes' : 'no'));
123
        if ($io->ask('Do you want to continue [Y/n]?', 'n') !== 'Y') {
124
            $io->abort('Bye');
125
        }
126
127
        $ok = $error = 0;
128
        $conditions = [];
129
        $this->defaultStatus = $args->getOption('status');
130
        foreach ($this->objectsIterator($conditions, $from, $to) as $object) {
131
            try {
132
                $io->verbose(sprintf('Translating object %s', $object->id));
133
                if (!$this->dryRun) {
134
                    $this->translate($object, $from, $to);
135
                }
136
                $io->verbose(sprintf('Translated object %s', $object->id));
137
                $ok++;
138
            } catch (\Exception $e) {
139
                $io->error(sprintf('Error translating object %s: %s', $object->id, $e->getMessage()));
140
                $error++;
141
            }
142
        }
143
        $io->out(sprintf('Processed %d objects (%d errors)', $ok + $error, $error));
144
        $io->out('Done');
145
    }
146
147
    /**
148
     * Get objects as iterable.
149
     *
150
     * @param array $conditions The conditions to filter objects.
151
     * @param string $lang The language to use to find objects.
152
     * @param string $to The language to translate objects to.
153
     * @return iterable
154
     */
155
    private function objectsIterator(array $conditions, string $lang, string $to): iterable
156
    {
157
        $table = $this->fetchTable('objects');
158
        $conditions = array_merge(
159
            $conditions,
160
            [
161
                $table->aliasField('deleted') => 0,
162
                $table->aliasField('lang') => $lang,
163
            ]
164
        );
165
        $query = $table->find('all')
166
            ->where($conditions)
167
            ->notMatching('Translations', function ($q) use ($to) {
168
                return $q->where(['Translations.lang' => $to]);
169
            })
170
            ->orderAsc($table->aliasField('id'))
171
            ->limit(500);
172
        $lastId = 0;
173
        while (true) {
174
            $q = clone $query;
175
            $q = $q->where(fn (QueryExpression $exp): QueryExpression => $exp->gt($table->aliasField('id'), $lastId));
176
            $results = $q->all();
177
            if ($results->isEmpty()) {
178
                break;
179
            }
180
181
            foreach ($results as $entity) {
182
                $lastId = $entity->id;
183
184
                yield $entity;
185
            }
186
        }
187
    }
188
189
    /**
190
     * Translate object translatable fields and store translation in translations table.
191
     *
192
     * @param \BEdita\Core\Model\Entity\ObjectEntity $object The object to translate
193
     * @param string $from The language to translate from
194
     * @param string $to The language to translate to
195
     * @return mixed
196
     */
197
    protected function translate($object, $from, $to)
198
    {
199
        $translatableFields = $this->translatableFields($object->type);
200
        if (empty($translatableFields)) {
201
            return;
202
        }
203
        $translatedFields = [];
204
        foreach ($translatableFields as $field) {
205
            if (empty($object->get($field))) {
206
                continue;
207
            }
208
            $translatedFields[$field] = $this->singleTranslation($object->get($field), $from, $to);
209
        }
210
        $translation = [
211
            'object_id' => $object->id,
212
            'lang' => array_flip($this->langsMap)[$to],
213
            'translated_fields' => json_encode($translatedFields),
214
            'status' => $this->defaultStatus,
215
        ];
216
        $table = $this->fetchTable('Translations');
217
        $entity = $table->newEntity($translation);
218
        $entity->set('translated_fields', json_decode($translation['translated_fields'], true));
219
        $entity->set('status', $this->defaultStatus);
220
        LoggedUser::setUserAdmin();
221
        $table->saveOrFail($entity);
222
    }
223
224
    /**
225
     * Get translatable fields by object type.
226
     *
227
     * @param string $type The object type
228
     * @return array
229
     */
230
    protected function translatableFields(string $type): array
231
    {
232
        if (array_key_exists($type, $this->translatableFields)) {
233
            return $this->translatableFields[$type];
234
        }
235
        /** @var \BEdita\Core\Model\Entity\ObjectType $objectType */
236
        $objectType = $this->fetchTable('ObjectTypes')->find()->where(['name' => $type])->firstOrFail();
237
        $schema = $objectType->get('schema');
238
        $this->translatableFields[$type] = (array)Hash::get($schema, 'translatable');
239
240
        return $this->translatableFields[$type];
241
    }
242
243
    /**
244
     * Translate a single text.
245
     *
246
     * @param mixed $text The text to translate
247
     * @param string $from The language to translate from
248
     * @param string $to The language to translate to
249
     * @return string
250
     */
251
    protected function singleTranslation($text, string $from, string $to): string
252
    {
253
        $response = $this->translator->translate([$text], $from, $to);
254
        $response = json_decode($response, true);
255
256
        return (string)Hash::get($response, 'translation.0');
257
    }
258
}
259