Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
23.53% covered (danger)
23.53%
16 / 68
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractTranslateJob
23.53% covered (danger)
23.53%
16 / 68
50.00% covered (danger)
50.00%
3 / 6
88.57
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTableAlias
n/a
0 / 0
n/a
0 / 0
0
 getRequiredArguments
n/a
0 / 0
n/a
0 / 0
0
 getDisplayNameArgument
n/a
0 / 0
n/a
0 / 0
0
 getEntityTypeName
n/a
0 / 0
n/a
0 / 0
0
 getFieldsForTranslation
n/a
0 / 0
n/a
0 / 0
0
 getHtmlFields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 useHtmlFormat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
44.83% covered (danger)
44.83%
13 / 29
0.00% covered (danger)
0.00%
0 / 1
9.20
 applyTranslations
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 handleEmptySeoFields
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare(strict_types=1);
3
4namespace App\Job;
5
6use App\Service\Api\Google\GoogleApiService;
7use App\Utility\SettingsManager;
8use Cake\Datasource\EntityInterface;
9use Cake\ORM\Table;
10use Cake\Queue\Job\Message;
11use Cake\Queue\QueueManager;
12use Interop\Queue\Processor;
13
14/**
15 * AbstractTranslateJob Class
16 *
17 * Base class for translation jobs providing common functionality for
18 * translating entities using the Google API service. Reduces code
19 * duplication across TranslateArticleJob, TranslateTagJob, and
20 * TranslateImageGalleryJob.
21 */
22abstract class AbstractTranslateJob extends AbstractJob
23{
24    /**
25     * @var \App\Service\Api\Google\GoogleApiService The API service instance.
26     */
27    protected GoogleApiService $apiService;
28
29    /**
30     * Constructor to allow dependency injection for testing
31     *
32     * @param \App\Service\Api\Google\GoogleApiService|null $googleService
33     */
34    public function __construct(?GoogleApiService $googleService = null)
35    {
36        $this->apiService = $googleService ?? new GoogleApiService();
37    }
38
39    /**
40     * Get the table alias for this entity type (e.g., 'Articles', 'Tags')
41     *
42     * @return string
43     */
44    abstract protected function getTableAlias(): string;
45
46    /**
47     * Get the message argument keys (e.g., ['id', 'title'] or ['id', 'name'])
48     *
49     * @return array<string>
50     */
51    abstract protected function getRequiredArguments(): array;
52
53    /**
54     * Get the display name argument key (e.g., 'title' or 'name')
55     * Used for logging purposes.
56     *
57     * @return string
58     */
59    abstract protected function getDisplayNameArgument(): string;
60
61    /**
62     * Get the entity type name for logging (e.g., 'Article', 'Tag', 'Gallery')
63     *
64     * @return string
65     */
66    abstract protected function getEntityTypeName(): string;
67
68    /**
69     * Get the fields to extract from the entity for translation.
70     * Returns an array of field names.
71     *
72     * @param \Cake\Datasource\EntityInterface $entity
73     * @return array<string, string>
74     */
75    abstract protected function getFieldsForTranslation(EntityInterface $entity): array;
76
77    /**
78     * Get HTML fields that need preprocessing (e.g., body content with code blocks)
79     *
80     * @return array<string>
81     */
82    protected function getHtmlFields(): array
83    {
84        return [];
85    }
86
87    /**
88     * Whether to use HTML format for translation
89     *
90     * @return bool
91     */
92    protected function useHtmlFormat(): bool
93    {
94        return false;
95    }
96
97    /**
98     * Execute the translation job
99     *
100     * @param \Cake\Queue\Job\Message $message The job message.
101     * @return string|null The result of the job execution.
102     */
103    public function execute(Message $message): ?string
104    {
105        $requiredArgs = $this->getRequiredArguments();
106        if (!$this->validateArguments($message, $requiredArgs)) {
107            return Processor::REJECT;
108        }
109
110        $id = $message->getArgument('id');
111        $displayName = $message->getArgument($this->getDisplayNameArgument());
112        $attempt = $message->getArgument('_attempt', 0);
113
114        // Check if translations are enabled
115        if (empty(array_filter(SettingsManager::read('Translations', [])))) {
116            $this->log(
117                sprintf('No languages enabled for translation: %s : %s', $id, $displayName),
118                'warning',
119                ['group_name' => static::class],
120            );
121
122            return Processor::REJECT;
123        }
124
125        $table = $this->getTable($this->getTableAlias());
126        $entity = $table->get($id);
127
128        // If there are empty SEO fields, requeue to wait for them to be populated
129        if (!empty($table->emptySeoFields($entity))) {
130            return $this->handleEmptySeoFields($id, $displayName, $attempt);
131        }
132
133        return $this->executeWithErrorHandling($id, function () use ($entity, $table) {
134            $fields = $this->getFieldsForTranslation($entity);
135            $result = $this->apiService->translateContent(
136                $fields,
137                $this->getHtmlFields(),
138                $this->useHtmlFormat(),
139            );
140
141            if ($result) {
142                $this->applyTranslations($entity, $table, $result);
143
144                return true;
145            }
146
147            return false;
148        }, $displayName);
149    }
150
151    /**
152     * Apply translations to entity and save
153     *
154     * @param \Cake\Datasource\EntityInterface $entity The entity to update
155     * @param \Cake\ORM\Table $table The table to save to
156     * @param array<string, array<string, string>> $translations Translations keyed by locale
157     * @return void
158     */
159    protected function applyTranslations(EntityInterface $entity, Table $table, array $translations): void
160    {
161        foreach ($translations as $locale => $translation) {
162            foreach ($translation as $field => $value) {
163                $entity->translation($locale)->{$field} = $value;
164            }
165            $table->save($entity, ['noMessage' => true]);
166        }
167    }
168
169    /**
170     * Handle the case where SEO fields are empty by requeuing the job
171     *
172     * @param string $id The entity ID
173     * @param string $displayName The entity display name (title/name)
174     * @param int $attempt The current attempt number
175     * @return string|null
176     */
177    protected function handleEmptySeoFields(string $id, string $displayName, int $attempt): ?string
178    {
179        if ($attempt >= 5) {
180            $this->logJobError(
181                $id,
182                sprintf('%s still has empty SEO fields after %d attempts', $this->getEntityTypeName(), $attempt),
183                $displayName,
184            );
185
186            return Processor::REJECT;
187        }
188
189        $data = [
190            'id' => $id,
191            $this->getDisplayNameArgument() => $displayName,
192            '_attempt' => $attempt + 1,
193        ];
194
195        QueueManager::push(
196            static::class,
197            $data,
198            [
199                'config' => 'default',
200                'delay' => 10 * ($attempt + 1),
201            ],
202        );
203
204        $this->log(
205            sprintf(
206                '%s has empty SEO fields, re-queuing with %d second delay: %s : %s',
207                $this->getEntityTypeName(),
208                10 * ($attempt + 1),
209                $id,
210                $displayName,
211            ),
212            'info',
213            ['group_name' => static::class],
214        );
215
216        return Processor::ACK;
217    }
218}