PluginProbe
Auto Alt Text / trunk
Auto Alt Text vtrunk
3.0.3 2.8.2 1.3.1 1.3.2 2.0.0 2.1.0 2.1.1 2.2.0 2.3.0 2.3.1 2.3.2 2.3.3 2.3.4 2.4.0 2.4.1 2.4.2 2.5.0 2.5.1 2.5.2 2.5.3 2.6.0 2.6.1 2.7.0 2.8.0 2.8.1 All 28 releases
← All changes | src/App/Admin/PluginOptions.php +551 -14 2.3.3trunk View file →
@@ -2,10 +2,16 @@
2 2
3 3 namespace AATXT\App\Admin;
4 4
5 5 use AATXT\App\Logging\DBLogger;
6 +use AATXT\App\AIProviders\Anthropic\AnthropicModelsRegistry;
7 +use AATXT\App\AIProviders\Gemini\GeminiModelsRegistry;
8 +use AATXT\App\AIProviders\OpenAI\OpenAIModelsRegistry;
6 9 use AATXT\App\AIProviders\Azure\AzureTranslator;
10 +use AATXT\App\Configuration\AzureConfig;
11 +use AATXT\App\Core\Container;
7 12 use AATXT\App\Exceptions\Azure\AzureTranslateInstanceException;
13 +use AATXT\App\Infrastructure\Http\WordPressHttpClient;
8 14 use AATXT\App\Utilities\AssetsManager;
9 15 use AATXT\App\Utilities\Encryption;
10 16 use AATXT\Config\Constants;
11 17
@@ -30,25 +36,311 @@
30 36 }
31 37
32 38 self::$assetsManager = AssetsManager::make();
33 39
40 + add_action('admin_notices', [self::$instance, 'showEncryptionConstantsNotice']);
41 +
34 42 add_action('admin_enqueue_scripts', [self::$instance, 'enqueueAdminScripts'], 1);
35 43 add_action('admin_menu', [self::$instance, 'addOptionsPageToTheMenu']);
36 - add_action('admin_init', [self::$instance, 'setupPluginOptions']);
44 + add_action('admin_init', [self::$instance, 'setupPluginOptions'], 10);
45 + add_action('admin_init', [self::$instance, 'migrateEncryptionKeys'], 9);
37 46
38 47 // Encrypt API Keys on update
39 48 add_action('pre_update_option_' . Constants::AATXT_OPTION_FIELD_API_KEY_AZURE_COMPUTER_VISION, [self::$instance, 'encryptDataOnUpdate'], 10, 3);
40 49 add_action('pre_update_option_' . Constants::AATXT_OPTION_FIELD_API_KEY_AZURE_TRANSLATE_INSTANCE, [self::$instance, 'encryptDataOnUpdate'], 10, 3);
41 50 add_action('pre_update_option_' . Constants::AATXT_OPTION_FIELD_API_KEY_OPENAI, [self::$instance, 'encryptDataOnUpdate'], 10, 3);
51 + add_action('pre_update_option_' . Constants::AATXT_OPTION_FIELD_API_KEY_ANTHROPIC, [self::$instance, 'encryptDataOnUpdate'], 10, 3);
52 + add_action('pre_update_option_' . Constants::AATXT_OPTION_FIELD_API_KEY_GEMINI, [self::$instance, 'encryptDataOnUpdate'], 10, 3);
53 +
54 + add_action('admin_notices', [self::$instance, 'encryptionErrorNotice']);
55 + add_action('admin_notices', [self::$instance, 'anthropicModelUnavailableNotice']);
56 + add_action('admin_notices', [self::$instance, 'openAiModelUnavailableNotice']);
57 + add_action('admin_notices', [self::$instance, 'geminiModelUnavailableNotice']);
58 +
59 + // Bust the cached Anthropic models list whenever the API key changes,
60 + // so the next admin page load fetches the model list with the new credentials.
61 + add_action('update_option_' . Constants::AATXT_OPTION_FIELD_API_KEY_ANTHROPIC, [self::$instance, 'flushAnthropicModelsCache']);
62 + add_action('add_option_' . Constants::AATXT_OPTION_FIELD_API_KEY_ANTHROPIC, [self::$instance, 'flushAnthropicModelsCache']);
63 +
64 + // Same for the cached OpenAI models list.
65 + add_action('update_option_' . Constants::AATXT_OPTION_FIELD_API_KEY_OPENAI, [self::$instance, 'flushOpenAiModelsCache']);
66 + add_action('add_option_' . Constants::AATXT_OPTION_FIELD_API_KEY_OPENAI, [self::$instance, 'flushOpenAiModelsCache']);
67 +
68 + // Same for the cached Gemini models list.
69 + add_action('update_option_' . Constants::AATXT_OPTION_FIELD_API_KEY_GEMINI, [self::$instance, 'flushGeminiModelsCache']);
70 + add_action('add_option_' . Constants::AATXT_OPTION_FIELD_API_KEY_GEMINI, [self::$instance, 'flushGeminiModelsCache']);
42 71 }
43 72
44 73 /**
74 + * Resolve the Anthropic models registry from the DI container.
75 + */
76 + private static function anthropicModelsRegistry(): AnthropicModelsRegistry
77 + {
78 + return Container::make()->get(AnthropicModelsRegistry::class);
79 + }
80 +
81 + public function flushAnthropicModelsCache(): void
82 + {
83 + self::anthropicModelsRegistry()->flushCache();
84 + }
85 +
86 + /**
87 + * Resolve the OpenAI models registry from the DI container.
88 + */
89 + private static function openAiModelsRegistry(): OpenAIModelsRegistry
90 + {
91 + return Container::make()->get(OpenAIModelsRegistry::class);
92 + }
93 +
94 + public function flushOpenAiModelsCache(): void
95 + {
96 + self::openAiModelsRegistry()->flushCache();
97 + }
98 +
99 + /**
100 + * Resolve the Gemini models registry from the DI container.
101 + */
102 + private static function geminiModelsRegistry(): GeminiModelsRegistry
103 + {
104 + return Container::make()->get(GeminiModelsRegistry::class);
105 + }
106 +
107 + public function flushGeminiModelsCache(): void
108 + {
109 + self::geminiModelsRegistry()->flushCache();
110 + }
111 +
112 + /**
113 + * Warn the admin if the OpenAI model currently saved in the options
114 + * is no longer returned by the registry (typically because OpenAI
115 + * has retired it). The user has to pick a replacement; in the meantime
116 + * OpenAIVision falls back to the least expensive available model so
117 + * generation keeps working.
118 + */
119 + public function openAiModelUnavailableNotice(): void
120 + {
121 + if (!current_user_can('manage_options')) {
122 + return;
123 + }
124 +
125 + $savedModel = get_option(Constants::AATXT_OPTION_FIELD_MODEL_OPENAI);
126 + if (!is_string($savedModel) || $savedModel === '') {
127 + return;
128 + }
129 +
130 + $registry = self::openAiModelsRegistry();
131 + if (!$registry->hasApiKey()) {
132 + return;
133 + }
134 +
135 + if ($registry->isAvailable($savedModel)) {
136 + return;
137 + }
138 +
139 + $settingsUrl = esc_url(menu_page_url(Constants::AATXT_PLUGIN_OPTIONS_PAGE_SLUG, false));
140 +
141 + echo '<div class="notice notice-warning is-dismissible">';
142 + echo '<p><strong>' . esc_html__('Auto Alt Text', 'auto-alt-text') . ':</strong> ';
143 + printf(
144 + /* translators: %s is the model id that is no longer available */
145 + esc_html__('the OpenAI model "%s" you previously selected is no longer available. Please choose a new model in the plugin settings.', 'auto-alt-text'),
146 + esc_html($savedModel)
147 + );
148 + echo ' <a href="' . $settingsUrl . '">' . esc_html__('Go to settings page', 'auto-alt-text') . '</a>.';
149 + echo '</p></div>';
150 + }
151 +
152 + /**
153 + * Warn the admin if the Anthropic model currently saved in the options
154 + * is no longer returned by the registry (typically because Anthropic
155 + * has retired it). The user has to pick a replacement; in the meantime
156 + * AnthropicResponse falls back to the most recent available model so
157 + * generation keeps working.
158 + */
159 + public function anthropicModelUnavailableNotice(): void
160 + {
161 + if (!current_user_can('manage_options')) {
162 + return;
163 + }
164 +
165 + $savedModel = get_option(Constants::AATXT_OPTION_FIELD_MODEL_ANTHROPIC);
166 + if (!is_string($savedModel) || $savedModel === '') {
167 + return;
168 + }
169 +
170 + $registry = self::anthropicModelsRegistry();
171 + if (!$registry->hasApiKey()) {
172 + return;
173 + }
174 +
175 + if ($registry->isAvailable($savedModel)) {
176 + return;
177 + }
178 +
179 + $settingsUrl = esc_url(menu_page_url(Constants::AATXT_PLUGIN_OPTIONS_PAGE_SLUG, false));
180 +
181 + echo '<div class="notice notice-warning is-dismissible">';
182 + echo '<p><strong>' . esc_html__('Auto Alt Text', 'auto-alt-text') . ':</strong> ';
183 + printf(
184 + /* translators: %s is the model id that is no longer available */
185 + esc_html__('the Anthropic model "%s" you previously selected is no longer available. Please choose a new model in the plugin settings.', 'auto-alt-text'),
186 + esc_html($savedModel)
187 + );
188 + echo ' <a href="' . $settingsUrl . '">' . esc_html__('Go to settings page', 'auto-alt-text') . '</a>.';
189 + echo '</p></div>';
190 + }
191 +
192 + /**
193 + * Warn the admin if the Gemini model currently saved in the options
194 + * is no longer returned by the registry (typically because Google
195 + * has retired it). The user has to pick a replacement; in the meantime
196 + * GeminiResponse falls back to the least expensive available model so
197 + * generation keeps working.
198 + */
199 + public function geminiModelUnavailableNotice(): void
200 + {
201 + if (!current_user_can('manage_options')) {
202 + return;
203 + }
204 +
205 + $savedModel = get_option(Constants::AATXT_OPTION_FIELD_MODEL_GEMINI);
206 + if (!is_string($savedModel) || $savedModel === '') {
207 + return;
208 + }
209 +
210 + $registry = self::geminiModelsRegistry();
211 + if (!$registry->hasApiKey()) {
212 + return;
213 + }
214 +
215 + if ($registry->isAvailable($savedModel)) {
216 + return;
217 + }
218 +
219 + $settingsUrl = esc_url(menu_page_url(Constants::AATXT_PLUGIN_OPTIONS_PAGE_SLUG, false));
220 +
221 + echo '<div class="notice notice-warning is-dismissible">';
222 + echo '<p><strong>' . esc_html__('Auto Alt Text', 'auto-alt-text') . ':</strong> ';
223 + printf(
224 + /* translators: %s is the model id that is no longer available */
225 + esc_html__('the Gemini model "%s" you previously selected is no longer available. Please choose a new model in the plugin settings.', 'auto-alt-text'),
226 + esc_html($savedModel)
227 + );
228 + echo ' <a href="' . $settingsUrl . '">' . esc_html__('Go to settings page', 'auto-alt-text') . '</a>.';
229 + echo '</p></div>';
230 + }
231 +
232 + /**
233 + * Show an error notice if the encryption of the API Key fails
234 + * @return void
235 + */
236 + public function encryptionErrorNotice(): void
237 + {
238 + $raw = get_option(Constants::AATXT_OPTION_FIELD_API_KEY_OPENAI);
239 + $decrypted = Encryption::make()->decrypt((string) $raw);
240 +
241 + if ($raw && $decrypted === '') {
242 + $screen = get_current_screen();
243 + $settingsScreenId = 'toplevel_page_' . Constants::AATXT_PLUGIN_OPTIONS_PAGE_SLUG;
244 + $showLink = ! ( $screen && $screen->id === $settingsScreenId );
245 +
246 + $settingsUrl = esc_url( menu_page_url(Constants::AATXT_PLUGIN_OPTIONS_PAGE_SLUG, false) );
247 +
248 + echo '<div class="notice notice-error is-dismissible">';
249 + echo '<p>';
250 +
251 + echo '<strong>' . esc_html__('Auto Alt Text', 'auto-alt-text') . ':</strong> ';
252 +
253 + echo esc_html__(
254 + 'There was a problem with the encryption of your API Key for alt text generation. Please re-enter the key and save.',
255 + 'auto-alt-text'
256 + );
257 +
258 + if ($showLink) {
259 + echo ' <a href="' . $settingsUrl . '">'
260 + . esc_html__('Go to settings page', 'auto-alt-text')
261 + . '</a>.';
262 + }
263 + echo '</p>';
264 + echo '</div>';
265 + }
266 + }
267 +
268 + /**
269 + * Print an admin notice containing the defines to be added
270 + * to the wp-config.php.
271 + */
272 + public function showEncryptionConstantsNotice(): void
273 + {
274 + if ( ! is_admin() || ! current_user_can('manage_options') ) {
275 + return;
276 + }
277 +
278 + $screen = function_exists('get_current_screen') ? get_current_screen() : null;
279 + if ( ! $screen || $screen->id !== 'toplevel_page_' . Constants::AATXT_PLUGIN_OPTIONS_PAGE_SLUG ) {
280 + return;
281 + }
282 +
283 + if ( defined('AATXT_ENCRYPTION_KEY') && defined('AATXT_ENCRYPTION_SALT') ) {
284 + return;
285 + }
286 +
287 + $suggestedKey = bin2hex(random_bytes(32));
288 + $suggestedSalt = bin2hex(random_bytes(32));
289 + ?>
290 + <div class="notice notice-info is-dismissible">
291 + <p>
292 + <?php
293 + printf(
294 + /* translators: “Optional” label */
295 + __( '%s: you can add two lines to your wp-config.php to make your API key more resilient if WordPress salts ever change.', 'auto-alt-text' ),
296 + '<strong>' . esc_html__( 'Optional', 'auto-alt-text' ) . '</strong>'
297 + );
298 + ?>
299 + </p>
300 +<pre style="background:#f5f5f5; padding:10px; border-radius:4px;">
301 +define('AATXT_ENCRYPTION_KEY', '<?php echo esc_html( $suggestedKey ); ?>');
302 +define('AATXT_ENCRYPTION_SALT', '<?php echo esc_html( $suggestedSalt ); ?>');
303 +</pre>
304 + <p>
305 + <?php esc_html_e(
306 + 'Just paste them before the line “/* That\'s all, stop editing! Happy publishing. */” in wp-config.php.',
307 + 'auto-alt-text'
308 + ); ?>
309 + </p>
310 + <p>
311 + <?php esc_html_e(
312 + "If you don't have the ability to edit the wp-config.php, don't worry because the plugin will still work without this change. If your site's salting keys change in the future, you will simply need to resave your API Key in the options below.",
313 + "auto-alt-text"
314 + ); ?>
315 + </p>
316 + </div>
317 + <?php
318 + }
319 +
320 + public function migrateEncryptionKeys(): void
321 + {
322 + if ( ! defined('AATXT_ENCRYPTION_KEY') || ! defined('AATXT_ENCRYPTION_SALT') ) {
323 + return;
324 + }
325 +
326 + update_option(Constants::AATXT_LEGACY_ENCRYPTION_MIGRATION_DONE, '0');
327 +
328 + if ( get_option(Constants::AATXT_LEGACY_ENCRYPTION_MIGRATION_DONE) ) {
329 + return;
330 + }
331 +
332 + Encryption::make()->migrateLegacyApiKeys();
333 +
334 + update_option(Constants::AATXT_LEGACY_ENCRYPTION_MIGRATION_DONE, '1');
335 + }
336 +
337 + /**
45 338 * Encrypt data
46 339 * @param ?string $newValue
47 - * @param ?string $oldValue
48 340 * @return ?string
49 341 */
50 - public function encryptDataOnUpdate(?string $newValue, ?string $oldValue): ?string
342 + public function encryptDataOnUpdate(?string $newValue): ?string
51 343 {
52 344 if (!empty($newValue)) {
53 345 $newValue = (Encryption::make())->encrypt($newValue);
54 346 }
@@ -102,9 +394,11 @@
102 394 <br>
103 395 <?php esc_html_e("Each line corresponds to an error generated by a call to the Azure or OpenAI API after uploading an image.", 'auto-alt-text'); ?>
104 396 </p>
105 397 <?php
106 - $logs = DBLogger::make()->getImageLog();
398 + $container = Container::make();
399 + $logger = $container->get(DBLogger::class);
400 + $logs = $logger->getImageLog();
107 401 if ($logs) {
108 402 echo '<textarea id="error-log" name="error-log" readonly>' . esc_textarea($logs) . '</textarea>';
109 403 } else {
110 404 echo '<p>' . esc_html__('There is no error log yet!', 'auto-alt-text') . '</p>';
@@ -136,8 +430,11 @@
136 430 <li>
137 431 <strong><?php esc_html_e("OpenAI's APIs", 'auto-alt-text'); ?></strong>: <?php esc_html_e("the image will be analyzed by the AI services provided by OpenAI and an alt text will be generated based on the prompt you set;", 'auto-alt-text'); ?>
138 432 </li>
139 433 <li>
434 + <strong><?php esc_html_e("Google Gemini's APIs", 'auto-alt-text'); ?></strong>: <?php esc_html_e("the image will be analyzed by the AI services provided by Google and an alt text will be generated based on the prompt you set;", 'auto-alt-text'); ?>
435 + </li>
436 + <li>
140 437 <strong><?php esc_html_e("Azure's APIs", 'auto-alt-text'); ?></strong>: <?php esc_html_e("the image will be analyzed by the AI services provided by Azure and an alt text will be generated in the language of your choice;", 'auto-alt-text'); ?>
141 438 </li>
142 439 <li>
143 440 <strong><?php esc_html_e("Title of the article (not AI)", 'auto-alt-text'); ?></strong>: <?php esc_html_e("if the image is uploaded within an article, the title of the article will be used as alt text;", 'auto-alt-text'); ?>
@@ -167,8 +464,10 @@
167 464 <select name="<?php echo esc_attr(Constants::AATXT_OPTION_FIELD_TYPOLOGY); ?>"
168 465 id="<?php echo esc_attr(Constants::AATXT_OPTION_FIELD_TYPOLOGY); ?>">
169 466 <option value="<?php echo esc_attr(Constants::AATXT_OPTION_TYPOLOGY_DEACTIVATED); ?>"<?php echo esc_attr(self::selected($typology, Constants::AATXT_OPTION_TYPOLOGY_DEACTIVATED)); ?>><?php esc_html_e("Deactivated", 'auto-alt-text'); ?></option>
170 467 <option value="<?php echo esc_attr(Constants::AATXT_OPTION_TYPOLOGY_CHOICE_OPENAI); ?>"<?php echo esc_attr(self::selected($typology, Constants::AATXT_OPTION_TYPOLOGY_CHOICE_OPENAI)); ?>><?php esc_html_e("OpenAI's APIs", 'auto-alt-text'); ?></option>
468 + <option value="<?php echo esc_attr(Constants::AATXT_OPTION_TYPOLOGY_CHOICE_ANTHROPIC); ?>"<?php echo esc_attr(self::selected($typology, Constants::AATXT_OPTION_TYPOLOGY_CHOICE_ANTHROPIC)); ?>><?php esc_html_e("Antrhopic's APIs", 'auto-alt-text'); ?></option>
469 + <option value="<?php echo esc_attr(Constants::AATXT_OPTION_TYPOLOGY_CHOICE_GEMINI); ?>"<?php echo esc_attr(self::selected($typology, Constants::AATXT_OPTION_TYPOLOGY_CHOICE_GEMINI)); ?>><?php esc_html_e("Google Gemini's APIs", 'auto-alt-text'); ?></option>
171 470 <option value="<?php echo esc_attr(Constants::AATXT_OPTION_TYPOLOGY_CHOICE_AZURE); ?>"<?php echo esc_attr(self::selected($typology, Constants::AATXT_OPTION_TYPOLOGY_CHOICE_AZURE)); ?>><?php esc_html_e("Azure's APIs", 'auto-alt-text'); ?></option>
172 471 <option value="<?php echo esc_attr(Constants::AATXT_OPTION_TYPOLOGY_CHOICE_ARTICLE_TITLE); ?>"<?php echo esc_attr(self::selected($typology, Constants::AATXT_OPTION_TYPOLOGY_CHOICE_ARTICLE_TITLE)); ?>><?php esc_html_e("Title of the article (not AI)", 'auto-alt-text'); ?></option>
173 472 <option value="<?php echo esc_attr(Constants::AATXT_OPTION_TYPOLOGY_CHOICE_ATTACHMENT_TITLE); ?>"<?php echo esc_attr(self::selected($typology, Constants::AATXT_OPTION_TYPOLOGY_CHOICE_ATTACHMENT_TITLE)); ?>><?php esc_html_e("Title of the attachment (not AI)", 'auto-alt-text'); ?></option>
174 473 </select>
@@ -180,22 +479,60 @@
180 479 esc_html__('Therefore, the alt text "Auto draft" will be inserted. To avoid this behavior, save the article draft first and then upload the image.', 'auto-alt-text') .
181 480 '</div>';
182 481
183 482 $openaiModel = self::openAiModel();
483 + $openaiRegistry = self::openAiModelsRegistry();
184 484
185 485 echo '<div class="plugin-option type-openai">';
186 486 echo '<label for="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_OPENAI) . '">' . esc_html__('Model', 'auto-alt-text') . '</label>';
187 487
188 - echo '<select name="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_OPENAI) . '" id="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_OPENAI) . '">';
189 - foreach(Constants::AATXT_OPTION_FIELD_MODEL_OPENAI_OPTIONS as $key => $value) {
190 - echo '<option value="' . esc_attr($key) . '" ' . esc_attr(self::selected($openaiModel, $key)) . '>' . esc_html($value) . '</option>';
488 + if (!$openaiRegistry->hasApiKey()) {
489 + echo '<p class="description">' . esc_html__('Enter and save your OpenAI API Key to load the list of available models.', 'auto-alt-text') . '</p>';
490 + // The <select> below is disabled and therefore not submitted with
491 + // the form; this hidden field re-submits the currently stored model
492 + // so saving the settings page does not wipe the user's choice.
493 + $rawSavedModel = get_option(Constants::AATXT_OPTION_FIELD_MODEL_OPENAI);
494 + $rawSavedModel = is_string($rawSavedModel) ? $rawSavedModel : '';
495 + echo '<input type="hidden" name="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_OPENAI) . '" value="' . esc_attr($rawSavedModel) . '">';
496 + echo '<select id="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_OPENAI) . '" disabled>';
497 + if ($openaiModel !== '') {
498 + echo '<option value="' . esc_attr($openaiModel) . '" selected>' . esc_html($openaiModel) . '</option>';
499 + } else {
500 + echo '<option value="" selected>' . esc_html__('— API Key required —', 'auto-alt-text') . '</option>';
501 + }
502 + echo '</select>';
503 + } else {
504 + $availableModels = $openaiRegistry->getAvailableModels();
505 +
506 + // Read the raw saved value (not self::openAiModel(), which falls
507 + // back to the static gpt-4o default). This lets us tell "nothing
508 + // saved yet" apart from "gpt-4o explicitly chosen": on a fresh
509 + // install we pre-select the registry's default (least expensive
510 + // available) model instead of flagging gpt-4o as no longer
511 + // available for a model the user never picked.
512 + $savedModel = get_option(Constants::AATXT_OPTION_FIELD_MODEL_OPENAI);
513 + $savedModel = is_string($savedModel) ? $savedModel : '';
514 + $selectedModel = $savedModel !== '' ? $savedModel : $openaiRegistry->getDefaultModel();
515 + $savedIsLegacy = $savedModel !== '' && !array_key_exists($savedModel, $availableModels);
516 +
517 + echo '<select name="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_OPENAI) . '" id="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_OPENAI) . '">';
518 + foreach ($availableModels as $key => $value) {
519 + echo '<option value="' . esc_attr($key) . '" ' . esc_attr(self::selected($selectedModel, $key)) . '>' . esc_html($value) . '</option>';
520 + }
521 + if ($savedIsLegacy) {
522 + $legacyLabel = sprintf(
523 + /* translators: %s is the saved model id */
524 + esc_html__('%s (no longer available)', 'auto-alt-text'),
525 + $savedModel
526 + );
527 + echo '<option value="' . esc_attr($savedModel) . '" selected>' . esc_html($legacyLabel) . '</option>';
528 + }
529 + echo '</select>';
191 530 }
192 - echo '</select>';
193 531 echo '</div>';
194 532
195 533 echo '<div class="plugin-option type-openai"><strong>' . esc_html__('Notice', 'auto-alt-text') . '</strong>: ' .
196 - esc_html__('Rarely it may happen that the chosen model fails to generate correct alt text for the image.', 'auto-alt-text') . ' ' .
197 - esc_html__('In these cases, the call to the api will be re-performed using the gpt-4o-mini model as fallback.', 'auto-alt-text') . '<br>' .
534 + esc_html__('If the model you selected is retired by OpenAI and is no longer available, the least expensive available model will be used as fallback until you choose a new one.', 'auto-alt-text') . '<br>' .
198 535 esc_html__('In case of errors, it is still possible to find the specific reason stated on the', 'auto-alt-text') . ' <a href="' . esc_url(menu_page_url(Constants::AATXT_PLUGIN_OPTION_LOG_PAGE_SLUG, false)) . '">' . esc_html__('error log page', 'auto-alt-text') . '</a>.' .
199 536 '</div>';
200 537
201 538 echo '<div class="plugin-option type-openai">';
@@ -262,9 +599,21 @@
262 599 echo '<p class="description">' . esc_html__("Select the language in which the alt text should be written.", 'auto-alt-text') . '</p>';
263 600 $currentLanguage = get_option(Constants::AATXT_OPTION_FIELD_LANGUAGE_AZURE_TRANSLATE_INSTANCE);
264 601
265 602 try {
266 - $supportedLanguages = (AzureTranslator::make())->supportedLanguages();
603 + $httpClient = new WordPressHttpClient();
604 + $azureConfig = new AzureConfig(
605 + self::apiKeyAzureComputerVision(),
606 + self::endpointAzureComputerVision(),
607 + '',
608 + '',
609 + self::apiKeyAzureTranslateInstance(),
610 + self::endpointAzureTranslateInstance(),
611 + self::regionAzureTranslateInstance(),
612 + self::languageAzureTranslateInstance()
613 + );
614 + $azureTranslator = new AzureTranslator($httpClient, $azureConfig);
615 + $supportedLanguages = $azureTranslator->supportedLanguages();
267 616 } catch (AzureTranslateInstanceException $e) {
268 617 $supportedLanguages = [
269 618 "en" => [
270 619 "name" => "English",
@@ -291,9 +640,126 @@
291 640
292 641 echo '</div>';
293 642 endif;
294 643
295 - echo '<div class="plugin-option type-article-title type-attachment-title type-openai type-azure">';
644 + echo '<div class="plugin-option type-anthropic">';
645 + echo '<label for="' . esc_attr(Constants::AATXT_OPTION_FIELD_API_KEY_ANTHROPIC) . '">' . esc_html__('Antrhopic API Key', 'auto-alt-text') . '</label>';
646 + echo '<p class="description">' . esc_html__("Enter your API Key", 'auto-alt-text') . '</p>';
647 + $apiKey = get_option(Constants::AATXT_OPTION_FIELD_API_KEY_ANTHROPIC);
648 + echo '<input type="password" name="' . esc_attr(Constants::AATXT_OPTION_FIELD_API_KEY_ANTHROPIC) . '" value="' . esc_attr((Encryption::make())->decrypt($apiKey)) . '" />';
649 + echo '</div>';
650 +
651 + $anthropicModel = self::anthropicModel();
652 + $anthropicRegistry = self::anthropicModelsRegistry();
653 +
654 + echo '<div class="plugin-option type-anthropic">';
655 + echo '<label for="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_ANTHROPIC) . '">' . esc_html__('Model', 'auto-alt-text') . '</label>';
656 +
657 + if (!$anthropicRegistry->hasApiKey()) {
658 + echo '<p class="description">' . esc_html__('Enter and save your Anthropic API Key to load the list of available models.', 'auto-alt-text') . '</p>';
659 + // The <select> below is disabled and therefore not submitted with
660 + // the form; this hidden field re-submits the currently stored model
661 + // so saving the settings page does not wipe the user's choice.
662 + $rawSavedModel = get_option(Constants::AATXT_OPTION_FIELD_MODEL_ANTHROPIC);
663 + $rawSavedModel = is_string($rawSavedModel) ? $rawSavedModel : '';
664 + echo '<input type="hidden" name="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_ANTHROPIC) . '" value="' . esc_attr($rawSavedModel) . '">';
665 + echo '<select id="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_ANTHROPIC) . '" disabled>';
666 + if ($anthropicModel !== '') {
667 + echo '<option value="' . esc_attr($anthropicModel) . '" selected>' . esc_html($anthropicModel) . '</option>';
668 + } else {
669 + echo '<option value="" selected>' . esc_html__('— API Key required —', 'auto-alt-text') . '</option>';
670 + }
671 + echo '</select>';
672 + } else {
673 + $availableModels = $anthropicRegistry->getAvailableModels();
674 + $savedIsLegacy = $anthropicModel !== '' && !array_key_exists($anthropicModel, $availableModels);
675 +
676 + echo '<select name="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_ANTHROPIC) . '" id="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_ANTHROPIC) . '">';
677 + foreach ($availableModels as $key => $value) {
678 + echo '<option value="' . esc_attr($key) . '" ' . esc_attr(self::selected($anthropicModel, $key)) . '>' . esc_html($value) . '</option>';
679 + }
680 + if ($savedIsLegacy) {
681 + $legacyLabel = sprintf(
682 + /* translators: %s is the saved model id */
683 + esc_html__('%s (no longer available)', 'auto-alt-text'),
684 + $anthropicModel
685 + );
686 + echo '<option value="' . esc_attr($anthropicModel) . '" selected>' . esc_html($legacyLabel) . '</option>';
687 + }
688 + echo '</select>';
689 + }
690 + echo '</div>';
691 +
692 + echo '<div class="plugin-option type-anthropic">';
693 + echo '<label for="' . esc_attr(Constants::AATXT_OPTION_FIELD_PROMPT_ANTHROPIC) . '">' . esc_html__('Prompt', 'auto-alt-text') . '</label>';
694 + echo '<p class="description">' . esc_html__("Enter a specific and detailed prompt according to your needs.", 'auto-alt-text') . '</p>';
695 + $defaultPrompt = sprintf(esc_html__("Act like an SEO expert and write an alt text of up to 125 characters for this image. Return only the complete alt text.", 'auto-alt-text'), Constants::AATXT_IMAGE_URL_TAG);
696 + $prompt = get_option(Constants::AATXT_OPTION_FIELD_PROMPT_ANTHROPIC) ?: $defaultPrompt;
697 + echo '<textarea name="' . esc_attr(Constants::AATXT_OPTION_FIELD_PROMPT_ANTHROPIC) . '" rows="5" cols="50">' . esc_textarea($prompt) . '</textarea>';
698 + echo '</div>';
699 +
700 + echo '<div class="plugin-option type-gemini">';
701 + echo '<label for="' . esc_attr(Constants::AATXT_OPTION_FIELD_API_KEY_GEMINI) . '">' . esc_html__('Gemini API Key', 'auto-alt-text') . '</label>';
702 + echo '<p class="description">' . esc_html__("Enter your API Key", 'auto-alt-text') . '</p>';
703 + $apiKey = get_option(Constants::AATXT_OPTION_FIELD_API_KEY_GEMINI);
704 + echo '<input type="password" name="' . esc_attr(Constants::AATXT_OPTION_FIELD_API_KEY_GEMINI) . '" value="' . esc_attr((Encryption::make())->decrypt($apiKey)) . '" />';
705 + echo '</div>';
706 +
707 + $geminiModel = self::geminiModel();
708 + $geminiRegistry = self::geminiModelsRegistry();
709 +
710 + echo '<div class="plugin-option type-gemini">';
711 + echo '<label for="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_GEMINI) . '">' . esc_html__('Model', 'auto-alt-text') . '</label>';
712 +
713 + if (!$geminiRegistry->hasApiKey()) {
714 + echo '<p class="description">' . esc_html__('Enter and save your Gemini API Key to load the list of available models.', 'auto-alt-text') . '</p>';
715 + // The <select> below is disabled and therefore not submitted with
716 + // the form; this hidden field re-submits the currently stored model
717 + // so saving the settings page does not wipe the user's choice.
718 + $rawSavedModel = get_option(Constants::AATXT_OPTION_FIELD_MODEL_GEMINI);
719 + $rawSavedModel = is_string($rawSavedModel) ? $rawSavedModel : '';
720 + echo '<input type="hidden" name="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_GEMINI) . '" value="' . esc_attr($rawSavedModel) . '">';
721 + echo '<select id="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_GEMINI) . '" disabled>';
722 + if ($geminiModel !== '') {
723 + echo '<option value="' . esc_attr($geminiModel) . '" selected>' . esc_html($geminiModel) . '</option>';
724 + } else {
725 + echo '<option value="" selected>' . esc_html__('— API Key required —', 'auto-alt-text') . '</option>';
726 + }
727 + echo '</select>';
728 + } else {
729 + $availableModels = $geminiRegistry->getAvailableModels();
730 + $savedIsLegacy = $geminiModel !== '' && !array_key_exists($geminiModel, $availableModels);
731 +
732 + echo '<select name="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_GEMINI) . '" id="' . esc_attr(Constants::AATXT_OPTION_FIELD_MODEL_GEMINI) . '">';
733 + foreach ($availableModels as $key => $value) {
734 + echo '<option value="' . esc_attr($key) . '" ' . esc_attr(self::selected($geminiModel, $key)) . '>' . esc_html($value) . '</option>';
735 + }
736 + if ($savedIsLegacy) {
737 + $legacyLabel = sprintf(
738 + /* translators: %s is the saved model id */
739 + esc_html__('%s (no longer available)', 'auto-alt-text'),
740 + $geminiModel
741 + );
742 + echo '<option value="' . esc_attr($geminiModel) . '" selected>' . esc_html($legacyLabel) . '</option>';
743 + }
744 + echo '</select>';
745 + }
746 + echo '</div>';
747 +
748 + echo '<div class="plugin-option type-gemini"><strong>' . esc_html__('Notice', 'auto-alt-text') . '</strong>: ' .
749 + esc_html__('If the model you selected is retired by Google and is no longer available, the least expensive available model will be used as fallback until you choose a new one.', 'auto-alt-text') . '<br>' .
750 + esc_html__('In case of errors, it is still possible to find the specific reason stated on the', 'auto-alt-text') . ' <a href="' . esc_url(menu_page_url(Constants::AATXT_PLUGIN_OPTION_LOG_PAGE_SLUG, false)) . '">' . esc_html__('error log page', 'auto-alt-text') . '</a>.' .
751 + '</div>';
752 +
753 + echo '<div class="plugin-option type-gemini">';
754 + echo '<label for="' . esc_attr(Constants::AATXT_OPTION_FIELD_PROMPT_GEMINI) . '">' . esc_html__('Prompt', 'auto-alt-text') . '</label>';
755 + echo '<p class="description">' . esc_html__("Enter a specific and detailed prompt according to your needs.", 'auto-alt-text') . '</p>';
756 + $defaultPrompt = sprintf(esc_html__("Act like an SEO expert and write an alt text of up to 125 characters for this image. Return only the complete alt text.", 'auto-alt-text'), Constants::AATXT_IMAGE_URL_TAG);
757 + $prompt = get_option(Constants::AATXT_OPTION_FIELD_PROMPT_GEMINI) ?: $defaultPrompt;
758 + echo '<textarea name="' . esc_attr(Constants::AATXT_OPTION_FIELD_PROMPT_GEMINI) . '" rows="5" cols="50">' . esc_textarea($prompt) . '</textarea>';
759 + echo '</div>';
760 +
761 + echo '<div class="plugin-option type-article-title type-attachment-title type-openai type-azure type-anthropic type-gemini">';
296 762 echo '<label for="' . esc_attr(Constants::AATXT_OPTION_FIELD_PRESERVE_EXISTING_ALT_TEXT) . '">' . esc_html__('Keep existing alt text', 'auto-alt-text') . '</label>';
297 763 echo '<p class="description">' . esc_html__("If checked, the existing alt text of images will not be overwritten.", 'auto-alt-text') . '</p>';
298 764 $preserveAltText = get_option(Constants::AATXT_OPTION_FIELD_PRESERVE_EXISTING_ALT_TEXT);
299 765 echo '<input type="checkbox" name="' . esc_attr(Constants::AATXT_OPTION_FIELD_PRESERVE_EXISTING_ALT_TEXT) . '" value="1" class="notRequired" ' . checked(1, $preserveAltText, false) . ' />';
@@ -298,8 +764,18 @@
298 764 $preserveAltText = get_option(Constants::AATXT_OPTION_FIELD_PRESERVE_EXISTING_ALT_TEXT);
299 765 echo '<input type="checkbox" name="' . esc_attr(Constants::AATXT_OPTION_FIELD_PRESERVE_EXISTING_ALT_TEXT) . '" value="1" class="notRequired" ' . checked(1, $preserveAltText, false) . ' />';
300 766 echo '</div>';
301 767
768 + // Standalone option (no .plugin-option class, so it is always visible
769 + // regardless of the selected generation method): this feature works on
770 + // the front-end content and is independent of how alt texts are created.
771 + echo '<div>';
772 + echo '<label for="' . esc_attr(Constants::AATXT_OPTION_FIELD_AUTO_RENDER_ALT_TEXT) . '">' . esc_html__('Automatically render alt text in content', 'auto-alt-text') . '</label>';
773 + echo '<p class="description">' . esc_html__("If checked, when a page is displayed, images in the content that have no alt text are automatically filled with the alt text stored in the media library (when available). It does not modify your saved content.", 'auto-alt-text') . '</p>';
774 + $autoRenderAltText = get_option(Constants::AATXT_OPTION_FIELD_AUTO_RENDER_ALT_TEXT);
775 + echo '<input type="checkbox" name="' . esc_attr(Constants::AATXT_OPTION_FIELD_AUTO_RENDER_ALT_TEXT) . '" value="1" class="notRequired" ' . checked(1, $autoRenderAltText, false) . ' />';
776 + echo '</div>';
777 +
302 778 submit_button();
303 779 ?>
304 780 </form>
305 781 </div>
@@ -337,8 +813,15 @@
337 813 register_setting('auto_alt_text_options', Constants::AATXT_OPTION_FIELD_ENDPOINT_AZURE_TRANSLATE_INSTANCE, [self::class, 'sanitizeUrl']);
338 814 register_setting('auto_alt_text_options', Constants::AATXT_OPTION_FIELD_REGION_AZURE_TRANSLATE_INSTANCE, [self::class, 'sanitizeText']);
339 815 register_setting('auto_alt_text_options', Constants::AATXT_OPTION_FIELD_LANGUAGE_AZURE_TRANSLATE_INSTANCE, [self::class, 'sanitizeText']);
340 816 register_setting('auto_alt_text_options', Constants::AATXT_OPTION_FIELD_PRESERVE_EXISTING_ALT_TEXT);
817 + register_setting('auto_alt_text_options', Constants::AATXT_OPTION_FIELD_AUTO_RENDER_ALT_TEXT);
818 + register_setting('auto_alt_text_options', Constants::AATXT_OPTION_FIELD_API_KEY_ANTHROPIC);
819 + register_setting('auto_alt_text_options', Constants::AATXT_OPTION_FIELD_PROMPT_ANTHROPIC, [self::class, 'sanitizeTextArea']);
820 + register_setting('auto_alt_text_options', Constants::AATXT_OPTION_FIELD_MODEL_ANTHROPIC, [self::class, 'sanitizeText']);
821 + register_setting('auto_alt_text_options', Constants::AATXT_OPTION_FIELD_API_KEY_GEMINI);
822 + register_setting('auto_alt_text_options', Constants::AATXT_OPTION_FIELD_PROMPT_GEMINI, [self::class, 'sanitizeTextArea']);
823 + register_setting('auto_alt_text_options', Constants::AATXT_OPTION_FIELD_MODEL_GEMINI, [self::class, 'sanitizeText']);
341 824 }
342 825
343 826 /**
344 827 * @return string
@@ -352,12 +835,22 @@
352 835 {
353 836 return get_option(Constants::AATXT_OPTION_FIELD_MODEL_OPENAI) ?: Constants::AATXT_GPT4O;
354 837 }
355 838
839 + public static function anthropicModel(): string
840 + {
841 + return get_option(Constants::AATXT_OPTION_FIELD_MODEL_ANTHROPIC) ?: Constants::AATXT_CLAUDE_FALLBACK_MODEL;
842 + }
843 +
844 + public static function geminiModel(): string
845 + {
846 + return get_option(Constants::AATXT_OPTION_FIELD_MODEL_GEMINI) ?: Constants::AATXT_GEMINI_FALLBACK_MODEL;
847 + }
848 +
356 849 /**
357 850 * @return string
358 851 */
359 - public static function prompt(): string
852 + public static function openAiPrompt(): string
360 853 {
361 854 return get_option(Constants::AATXT_OPTION_FIELD_PROMPT_OPENAI);
362 855 }
363 856
@@ -363,8 +856,24 @@
363 856
364 857 /**
365 858 * @return string
366 859 */
860 + public static function anthropicPrompt(): string
861 + {
862 + return get_option(Constants::AATXT_OPTION_FIELD_PROMPT_ANTHROPIC);
863 + }
864 +
865 + /**
866 + * @return string
867 + */
868 + public static function geminiPrompt(): string
869 + {
870 + return get_option(Constants::AATXT_OPTION_FIELD_PROMPT_GEMINI);
871 + }
872 +
873 + /**
874 + * @return string
875 + */
367 876 public static function apiKeyOpenAI(): string
368 877 {
369 878 $apiKey = get_option(Constants::AATXT_OPTION_FIELD_API_KEY_OPENAI);
370 879 return (Encryption::make())->decrypt($apiKey);
@@ -372,8 +881,26 @@
372 881
373 882 /**
374 883 * @return string
375 884 */
885 + public static function apiKeyAnthropic(): string
886 + {
887 + $apiKey = get_option(Constants::AATXT_OPTION_FIELD_API_KEY_ANTHROPIC);
888 + return (Encryption::make())->decrypt($apiKey);
889 + }
890 +
891 + /**
892 + * @return string
893 + */
894 + public static function apiKeyGemini(): string
895 + {
896 + $apiKey = get_option(Constants::AATXT_OPTION_FIELD_API_KEY_GEMINI);
897 + return (Encryption::make())->decrypt($apiKey);
898 + }
899 +
900 + /**
901 + * @return string
902 + */
376 903 public static function apiKeyAzureComputerVision(): string
377 904 {
378 905 $apiKey = get_option(Constants::AATXT_OPTION_FIELD_API_KEY_AZURE_COMPUTER_VISION);
379 906 return (Encryption::make())->decrypt($apiKey);
@@ -427,8 +954,19 @@
427 954 {
428 955 return get_option(Constants::AATXT_OPTION_FIELD_PRESERVE_EXISTING_ALT_TEXT);
429 956 }
430 957
958 + /**
959 + * Whether missing alt text should be filled automatically when rendering
960 + * post content on the front-end. Unchecked (disabled) by default.
961 + *
962 + * @return bool
963 + */
964 + public static function autoRenderAltText(): bool
965 + {
966 + return (bool) get_option(Constants::AATXT_OPTION_FIELD_AUTO_RENDER_ALT_TEXT);
967 + }
968 +
431 969 public static function sanitizeUrl($input): string
432 970 {
433 971 return sanitize_url($input);
434 972 }
@@ -441,6 +979,5 @@
441 979 public static function sanitizeTextArea($input): string
442 980 {
443 981 return sanitize_textarea_field($input);
444 982 }
445 -
446 983 }