Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
23.53% |
16 / 68 |
|
50.00% |
3 / 6 |
CRAP | |
0.00% |
0 / 1 |
| AbstractTranslateJob | |
23.53% |
16 / 68 |
|
50.00% |
3 / 6 |
88.57 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
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% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| useHtmlFormat | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| execute | |
44.83% |
13 / 29 |
|
0.00% |
0 / 1 |
9.20 | |||
| applyTranslations | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| handleEmptySeoFields | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
6 | |||
| 1 | <?php |
| 2 | declare(strict_types=1); |
| 3 | |
| 4 | namespace App\Job; |
| 5 | |
| 6 | use App\Service\Api\Google\GoogleApiService; |
| 7 | use App\Utility\SettingsManager; |
| 8 | use Cake\Datasource\EntityInterface; |
| 9 | use Cake\ORM\Table; |
| 10 | use Cake\Queue\Job\Message; |
| 11 | use Cake\Queue\QueueManager; |
| 12 | use 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 | */ |
| 22 | abstract 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 | } |