Plugin Directory

source: alttext-ai/trunk/includes/class-atai-attachment.php

Last change on this file was 3274972, checked in by alttextai, 4 days ago

Version 1.9.94: Security fix, improved compatibility with custom themes, performance optimization for Media Library

File size: 47.1 KB
Line 
1<?php
2  // For handling audio attachments, need access to wp_read_audio_metadata:
3  // cf: https://developer.wordpress.org/reference/functions/wp_generate_attachment_metadata/
4  if ( ! function_exists( 'wp_read_audio_metadata' ) ) {
5        require_once ABSPATH . 'wp-admin/includes/media.php';
6  }
7
8  // Ensure wp_get_attachment_metadata is defined:
9  if ( ! function_exists( 'wp_get_attachment_metadata' ) || ! function_exists( 'wp_generate_attachment_metadata' ) ) {
10        require_once ABSPATH . 'wp-admin/includes/image.php';
11  }
12
13/**
14 * The file that handles attachment/image logic.
15 *
16 *
17 * @link       https://alttext.ai
18 * @since      1.0.0
19 *
20 * @package    ATAI
21 * @subpackage ATAI/includes
22 */
23
24/**
25 * The attachment handling class.
26 *
27 * This is used to handle operations related to attachments.
28 *
29 *
30 * @since      1.0.0
31 * @package    ATAI
32 * @subpackage ATAI/includes
33 * @author     AltText.ai <info@alttext.ai>
34 */
35class ATAI_Attachment {
36  /**
37   * Generate alt text for an image/attachment.
38   *
39   * @since 1.0.0
40   * @access public
41   *
42   * @param integer $attachment_id  ID of the attachment.
43   * @param string  $attachment_url URL of the attachment. $attachment_id has priority if both are provided.
44   * @param string  $options        API Options to customize the API call.
45   */
46  public function generate_alt( $attachment_id, $attachment_url = null, $options = [] ) {
47    $api_key = ATAI_Utility::get_api_key();
48
49    // Bail early if no API key
50    if ( empty( $api_key ) ) {
51      return false;
52    }
53
54    // Bail early if attachment is not eligible
55    if ( $attachment_id && $this->is_attachment_eligible( $attachment_id ) === false ) {
56      return false;
57    }
58
59    // Merge options with defaults
60    $api_options = wp_parse_args(
61      $options,
62      array(
63        'overwrite'   => true,
64        'ecomm'       => [],
65        'keywords'    => [],
66        'lang' => ATAI_Utility::lang_for_attachment( $attachment_id )
67      )
68    );
69    $gpt_prompt = get_option('atai_gpt_prompt');
70    if ( !empty($gpt_prompt) ) {
71      $api_options['gpt_prompt'] = $gpt_prompt;
72    }
73
74    $model_name = get_option('atai_model_name');
75    if ( !empty($model_name) ) {
76      $api_options['model_name'] = $model_name;
77    }
78
79    if ( $attachment_id ) {
80      $attachment_url = wp_get_attachment_image_url( $attachment_id, 'full' );
81      $attachment_url = apply_filters( 'atai_attachment_url', $attachment_url, $attachment_id );
82      if ( empty($api_options['ecomm']) ) {
83        $api_options['ecomm'] = $this->get_ecomm_data( $attachment_id );
84      }
85      $api_options['ecomm'] = $this->filtered_ecomm_data( $attachment_id, $api_options['ecomm'] );
86
87      if ( ! count( $api_options['keywords'] ) ) {
88        if ( isset( $api_options['explicit_post_id'] ) && ! empty( $api_options['explicit_post_id'] ) ) {
89            $api_options['keywords'] = $this->get_seo_keywords( $attachment_id, $api_options['explicit_post_id'] );
90        } else {
91            $api_options['keywords'] = $this->get_seo_keywords( $attachment_id );
92        }
93        if ( ! count( $api_options['keywords'] ) && ( get_option( 'atai_keywords_title' ) === 'yes' ) ) {
94          $api_options['keyword_source'] = $this->post_title_seo_keywords( $attachment_id );
95        }
96      }
97    }
98
99    $api            = new ATAI_API( $api_key );
100    $response_code = null;
101    $max_retries = 5;
102    $delay = 1; // 1 second
103   
104    for ($attempt = 0; $attempt < $max_retries; $attempt++) {
105      $response = $api->create_image( $attachment_id, $attachment_url, $api_options, $response_code );
106 
107      if ($response_code != '429') {
108          break; // Exit if not rate-limited
109      }
110 
111      if ($attempt < $max_retries - 1) {
112          sleep($delay);
113          $delay *= 2; // (1s → 2s → 4s → 8s)
114      }
115    }
116
117    if ( ! is_array( $response ) ) {
118      return $response;
119    }
120
121    $alt_text = $response['alt_text'];
122    $alt_prefix = get_option('atai_alt_prefix');
123    $alt_suffix = get_option('atai_alt_suffix');
124
125    if ( ! empty( $alt_prefix ) ) {
126      $alt_text = trim( $alt_prefix ) . ' ' . $alt_text;
127    }
128
129    if ( ! empty( $alt_suffix ) ) {
130      $alt_text = $alt_text . ' ' . trim( $alt_suffix );
131    }
132
133    ATAI_Utility::record_atai_asset($attachment_id, $response['asset_id']);
134    update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt_text );
135
136    $post_value_updates = array();
137    if ( get_option( 'atai_update_title' ) === 'yes' ) {
138      $post_value_updates['post_title'] = $alt_text;
139    };
140
141    if ( get_option( 'atai_update_caption' ) === 'yes' ) {
142      $post_value_updates['post_excerpt'] = $alt_text;
143    };
144
145    if ( get_option( 'atai_update_description' ) === 'yes' ) {
146      $post_value_updates['post_content'] = $alt_text;
147    };
148
149    if ( ! empty( $post_value_updates ) ) {
150      $post_value_updates['ID'] = $attachment_id;
151      wp_update_post( $post_value_updates );
152    };
153
154    do_action( 'atai_alttext_generated', $attachment_id, $alt_text );
155
156    return $alt_text;
157  }
158
159  /**
160   * Check if an attachment is eligible for alt text generation.
161   *
162   * @since 1.0.10
163   * @access public
164   *
165   * @param integer $attachment_id  ID of the attachment.
166   *
167   * @return boolean  True if eligible, false otherwise.
168   */
169  public function is_attachment_eligible( $attachment_id, $context = 'generate' ) {
170
171    /** Check user-defined filter for eligibility. Bail early if this attachment is not eligible. **/
172    $custom_skip = apply_filters( 'atai_skip_attachment', false, $attachment_id );
173    if ( $custom_skip ) {
174      return false;
175    }
176
177    $meta = wp_get_attachment_metadata( $attachment_id );
178    $upload_info = wp_get_upload_dir();
179
180    $file = ( is_array($meta) && array_key_exists('file', $meta) ) ? ($upload_info['basedir'] . '/' . $meta['file']) : get_attached_file( $attachment_id );
181    if ( empty( $meta ) && file_exists( $file ) ) {
182      if ( ( get_option( 'atai_wp_generate_metadata' ) === 'no' ) ) {
183        $meta = array('width' => 100, 'height' => 100); // Default values assuming this is a valid image
184      }
185      else {
186        $meta = wp_generate_attachment_metadata( $attachment_id, $file );
187      }
188    }
189
190    $size = null;
191
192    // Local File check
193    if (file_exists($file)) {
194      $size = filesize($file);
195    }
196
197    // Check metadata first
198    if (!$size && isset($meta['filesize'])) {
199      $size = $meta['filesize'];
200    }
201
202    // Check offloaded plugin metadata
203    if (!$size) {
204      $offload_meta = get_post_meta($attachment_id, 'amazonS3_info', true) ?: get_post_meta($attachment_id, 'cloudinary_info', true);
205      if (isset($offload_meta['key'])) {
206        $external_url = wp_get_attachment_url($attachment_id);
207        $size = ATAI_Utility::get_attachment_size($external_url);
208      }
209    }
210
211    $width    = $meta['width'] ?? 0;
212    $height   = $meta['height'] ?? 0;
213    $size     = $size ? ($size / pow(1024, 2)) : null; // in MBs
214    $type     = wp_check_filetype($file) ?: [];
215    $extension = $type['ext'] ?? pathinfo($file, PATHINFO_EXTENSION);
216
217    // If unable to get extension from WP, try parsing filename directly:
218    if ( empty($extension) ) {
219      $extension = pathinfo($file, PATHINFO_EXTENSION);
220    }
221
222    $file_type_extensions = get_option( 'atai_type_extensions' );
223    $attachment_edit_url = get_edit_post_link($attachment_id);
224
225    // Logging reasons for ineligibility
226    if (! empty($file_type_extensions)) {
227      $valid_extensions = array_map('trim', explode(',', $file_type_extensions));
228      if (! in_array(strtolower($extension), $valid_extensions)) {
229        if ( $context === 'generate' ) {
230          ATAI_Utility::log_error(
231            sprintf(
232              '<a href="%s" target="_blank">Image #%d</a>: %s (%s)',
233              esc_url($attachment_edit_url),
234              (int) $attachment_id,
235              esc_html__('User setting image filtering: Filetype not allowed.', 'alttext-ai'),
236              esc_html($extension)
237            )
238          );
239        }
240        return false; // This image extension is not in their whitelist of allowed extensions
241      }
242    }
243
244    if (!in_array(strtolower($extension), ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'])) {
245      if ( $context === 'generate' ) {
246        ATAI_Utility::log_error(
247          sprintf(
248            '<a href="%s" target="_blank">Image #%d</a>: %s (%s)',
249            esc_url($attachment_edit_url),
250            (int) $attachment_id,
251            esc_html__('Unsupported extension.', 'alttext-ai'),
252            esc_html($extension)
253          )
254        );
255      }
256      return false;
257    }
258
259    if ($size === null || $size === false) {
260      if ($context === 'generate' && get_option('atai_skip_filenotfound') === 'yes') {
261        ATAI_Utility::log_error(
262          sprintf(
263            '<a href="%s" target="_blank">Image #%d</a>: %s',
264            esc_url($attachment_edit_url),
265            (int) $attachment_id,
266            esc_html__('File not found.', 'alttext-ai')
267          )
268        );
269      }
270      return false;
271    }
272
273    if ($size > 16) {
274      if ( $context === 'generate' ) {
275        ATAI_Utility::log_error(
276          sprintf(
277            '<a href="%s" target="_blank">Image #%d</a>: %s (%.2f MB)',
278            esc_url($attachment_edit_url),
279            (int) $attachment_id,
280            esc_html__('File size exceeds 16MB limit.', 'alttext-ai'),
281            $size
282          )
283        );
284      }
285      return false;
286    }
287
288    if ($width < 50 || $height < 50) {
289      if ( $context === 'generate' ) {
290        ATAI_Utility::log_error(
291          sprintf(
292            '<a href="%s" target="_blank">Image #%d</a>: %s (%dx%d)',
293            esc_url($attachment_edit_url),
294            (int) $attachment_id,
295            esc_html__('Image dimensions too small.', 'alttext-ai'),
296            $width,
297            $height
298          )
299        );
300      }
301      return false;
302    }
303
304    return true;
305  }
306
307  /**
308   * Return ecomm-specific data for alt text generation.
309   *
310   * @since 1.0.25
311   * @access public
312   *
313   * @param integer $attachment_id ID of the attachment.
314   *
315   * @return Array ["ecomm" => ["product" => <title>]] or empty array if not found.
316   */
317  public function get_ecomm_data( $attachment_id, $product_id = null ) {
318    if ( ( get_option( 'atai_ecomm' ) === 'no' ) || ! ATAI_Utility::has_woocommerce() ) {
319      return array();
320    }
321
322    if ( get_option( 'atai_ecomm_title' ) === 'yes' ) {
323      $post = get_post( $attachment_id );
324      if ( !empty( $post->post_title ) ) {
325        return array( 'product' => $post->post_title );
326      }
327    }
328
329    if ( isset($product_id) ) {
330      $product_post = get_post( $product_id );
331      return array( 'product' => $product_post->post_title );
332    }
333
334    global $wpdb;
335
336    $find_product_title_sql = <<<SQL
337SELECT parent_posts.post_title as product_title
338FROM {$wpdb->posts} parent_posts
339INNER JOIN {$wpdb->posts} image_posts
340    ON image_posts.post_parent = parent_posts.id
341WHERE
342    image_posts.id = %d
343AND
344    parent_posts.post_type = 'product'
345AND
346    parent_posts.post_status <> 'auto-draft'
347SQL;
348
349    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
350    $product_title_data = $wpdb->get_results( $wpdb->prepare($find_product_title_sql, $attachment_id) );
351
352    if ( count( $product_title_data ) == 0 || strlen( $product_title_data[0]->product_title ) == 0 ) {
353      return array();
354    }
355
356    $product_title = $product_title_data[0]->product_title;
357
358    return array( 'product' => $product_title );
359  }
360
361  /**
362   * Retrieve filtered ecomm data, if implemented.
363   *
364   * @since 1.0.34
365   * @access public
366   *
367   * @param integer $attachment_id  ID of the attachment.
368   * @param Array $ecomm_data Current array of ecomm data.
369   *
370   * @return Array New filtered array of ecomm data.
371   */
372  public function filtered_ecomm_data($attachment_id, $ecomm_data) {
373    /**
374     * Filter the ecomm data to use for alt text generation.
375     *
376     * This filter allows you to modify the ecommerce product/brand data before it is used for alt text generation.
377     * You might want to use this filter if you have specific product and/or brand data outside of the natively
378     * supported WooCommerce system.
379     *
380     * @param Array $ecomm_data The current array of ecomm_data. This array will have keys "product" and [optionally] "brand"
381     * @param int $attachment_id The ID of the attachment for which the alt text is being generated.
382     *
383     * @return array The ecommerce product and optional brand data to use. The array keys MUST match the following:
384     * 'product' => The name of the product.
385     * 'brand' => The brand name of the product. This is OPTIONAL.
386     *
387     * Example return values:
388     * Example 1 (both product + brand name): { "product" => "Air Jordan", "brand" => "Nike" }
389     * Example 2 (only product name): { "product" => "Air Jordan" }
390     */
391    $ecomm_data = apply_filters( 'atai_ecomm_data', $ecomm_data, $attachment_id );
392    return $ecomm_data;
393  }
394
395    /**
396     * Return array of keywords to use for alt text generation.
397     *
398     * @since 1.0.26
399     * @access public
400     *
401     * @param integer $attachment_id ID of the attachment.
402     *
403     * @return Array of keywords, or empty array if none.
404     */
405    public function get_seo_keywords( $attachment_id, $explicit_post_id = null ) {
406      if ( ( get_option( 'atai_keywords' ) === 'no' ) ) {
407        return array();
408      }
409
410      global $wpdb;
411      $post_id = NULL;
412
413      // Attempt to get the related post ID directly from WordPress based on the attachment:
414      $fetch_post_sql = "select post_parent from {$wpdb->posts} where ID = %d";
415      $post_results = $wpdb->get_results( $wpdb->prepare($fetch_post_sql, $attachment_id) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,,WordPress.DB.PreparedSQL.NotPrepared
416
417      if ( count( $post_results ) > 0 ) {
418        $post_id = $post_results[0]->post_parent;
419      }
420
421      if ( empty($post_id) && ! empty($explicit_post_id) ) {
422        $post_id = $explicit_post_id;
423      }
424     
425      // Fetch keywords from Yoast SEO.
426      $keywords = $this->yoast_seo_keywords( $attachment_id, $post_id );
427
428      //  Fetch keywords from All in One SEO.
429      if ( ! count( $keywords ) ) {
430        $keywords = $this->aio_seo_keywords( $attachment_id, $post_id );
431      }
432
433      // Fetch keywords from RankMath.
434      if ( ! count( $keywords ) ) {
435        $keywords = $this->rankmath_seo_keywords( $attachment_id, $post_id );
436      }
437
438      // Fetch keywords from SEOPress.
439      if ( ! count( $keywords ) ) {
440        $keywords = $this->seopress_seo_keywords( $attachment_id, $post_id );
441      }
442
443      // Fetch keywords from Squirrly SEO.
444      if ( ! count( $keywords ) ) {
445        $keywords = $this->squirrly_seo_keywords( $attachment_id, $post_id );
446      }
447
448      // Fetch keywords from The SEO Framework.
449      if ( ! count( $keywords ) ) {
450        $keywords = $this->theseoframework_seo_keywords( $attachment_id, $post_id );
451      }
452
453      // Fetch keywords from SmartCrawl Pro.
454      if ( ! count( $keywords ) ) {
455        $keywords = $this->smartcrawl_seo_keywords( $attachment_id, $post_id );
456      }
457
458      /**
459       * Filter the keywords to use for alt text generation.
460       *
461       * This filter allows you to modify the list of SEO keywords before they are used for alt text generation.
462       * You might want to use this filter if you have specific SEO needs that are not met by the built-in
463       * methods of fetching keywords from the supported SEO plugins.
464       *
465       * @param array $keywords The current list of SEO keywords.
466       *                         This may be a list fetched from one of the SEO plugins mentioned above,
467       *                         or it may be an empty array if no keywords were found.
468       * @param int $attachment_id The ID of the attachment for which the alt text is being generated.
469       * @param int $post_id The ID of the related post for the attachment.
470       *                     This is the post that the attachment is associated with.
471       *                     It may be null if no related post was found.
472       *
473       * @return array The modified list of SEO keywords.
474       *
475       * EXAMPLE USAGE:
476         function custom_atai_seo_keywords($keywords, $attachment_id, $post_id) {
477             $additional_keywords = array("cats climbing", "adorable cats", "orange cats", "cats and dogs");
478             $modified_keywords = array_merge($keywords, $additional_keywords);
479             return $modified_keywords;
480         }
481         add_filter('atai_seo_keywords', 'custom_atai_seo_keywords', 10, 3);
482       *
483       */
484      $keywords = apply_filters( 'atai_seo_keywords', $keywords, $attachment_id, $post_id );
485
486      return $keywords;
487    }
488
489    /**
490     * Return array of keywords from Yoast SEO.
491     *
492     * @since 1.0.28
493     * @access public
494     *
495     * @param integer $attachment_id ID of the attachment.
496     * @param integer $post_id ID of the post that has keywords. Can be NULL.
497     *
498     * @return Array of keywords, or empty array if none.
499     */
500    public function yoast_seo_keywords( $attachment_id, $post_id ) {
501      // Bail early if Yoast SEO is not installed.
502      if ( ! ATAI_Utility::has_yoast() ) {
503        return array();
504      }
505
506      global $wpdb;
507
508      // If post ID is null, we may still be able to get it directly from the Yoast data for this attachment:
509      if ( ! $post_id ) {
510        $yoast_post_sql = "select post_id from " . $wpdb->prefix . "yoast_seo_links where target_post_id = %d";
511        $results = $wpdb->get_results( $wpdb->prepare($yoast_post_sql, $attachment_id) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,,WordPress.DB.PreparedSQL.NotPrepared
512
513        if ( count( $results ) > 0 ) {
514          $post_id = $results[0]->post_id;
515        }
516      }
517
518       // If we don't have the post, we have to stop here.
519      if ( ! $post_id ) {
520        return array();
521      }
522
523      $keyword_sql = <<<SQL
524select meta_value as focus_keywords
525from {$wpdb->postmeta}
526where meta_key = '_yoast_wpseo_focuskw'
527  and post_id = %d
528SQL;
529
530      // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,,WordPress.DB.PreparedSQL.NotPrepared
531      $keywords = $wpdb->get_results( $wpdb->prepare($keyword_sql, $post_id) );
532
533      if ( count( $keywords ) == 0 || strlen( $keywords[0]->focus_keywords ) == 0 ) {
534        return array();
535      }
536
537      $final_keywords = explode( ',', $keywords[0]->focus_keywords );
538
539      // Retrieve related keyphrases, if any
540      $keyword_sql = <<<SQL
541select meta_value as related_keywords
542from {$wpdb->postmeta}
543where meta_key = '_yoast_wpseo_focuskeywords'
544  and post_id = %d
545SQL;
546
547      // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,,WordPress.DB.PreparedSQL.NotPrepared
548      $keywords = $wpdb->get_results( $wpdb->prepare($keyword_sql, $post_id) );
549
550      if ( count( $keywords ) > 0 ) {
551        $related_keywords = json_decode( $keywords[0]->related_keywords );
552        foreach ( $related_keywords as $keyword_data ) {
553          array_push( $final_keywords, $keyword_data->keyword );
554        }
555      }
556
557      return $final_keywords;
558    }
559
560    /**
561     * Return array of keywords from AllInOne SEO.
562     *
563     * @since 1.0.28
564     * @access public
565     *
566     * @param integer $attachment_id ID of the attachment.
567     * @param integer $post_id ID of the post that has keywords. Can be NULL.
568     *
569     * @return Array of keywords, or empty array if none.
570     */
571    public function aio_seo_keywords( $attachment_id, $post_id ) {
572      // Bail early if All in One SEO is not active.
573      if ( ! ATAI_Utility::has_aioseo() ) {
574        return array();
575      }
576
577      // Bail early if $post_id is null.
578      if ( ! $post_id ) {
579        return array();
580      }
581
582      global $wpdb;
583
584      $keyword_sql = <<<SQL
585select keyphrases
586from {$wpdb->prefix}aioseo_posts
587where post_id = %d;
588SQL;
589
590      // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,,WordPress.DB.PreparedSQL.NotPrepared
591      $keywords = $wpdb->get_results( $wpdb->prepare($keyword_sql, $post_id) );
592
593      if ( count( $keywords ) == 0 || strlen( $keywords[0]->keyphrases ) == 0 ) {
594        return array();
595      }
596
597      $keyphrase_data = json_decode( $keywords[0]->keyphrases );
598      $final_keywords = array( $keyphrase_data->focus->keyphrase );
599
600      if ( isset( $keyphrase_data->additional ) ) {
601        foreach ( $keyphrase_data->additional as $additional_data ) {
602          array_push( $final_keywords, $additional_data->keyphrase );
603        }
604      }
605
606      return $final_keywords;
607    }
608
609    /**
610     * Return array of keywords from RankMath.
611     *
612     * @since 1.0.28
613     * @access public
614     *
615     * @param integer $attachment_id ID of the attachment.
616     * @param integer $post_id ID of the post that has keywords. Can be NULL.
617     *
618     * @return Array of keywords, or empty array if none.
619     */
620    public function rankmath_seo_keywords( $attachment_id, $post_id ) {
621      // Bail early if RankMath is not active.
622      if ( ! ATAI_Utility::has_rankmath() ) {
623        return array();
624      }
625
626      // Bail early if $post_id is null.
627      if ( ! $post_id ) {
628        return array();
629      }
630
631      global $wpdb;
632
633      $keyword_sql = <<<SQL
634select meta_value as focus_keywords
635from {$wpdb->postmeta}
636where meta_key = 'rank_math_focus_keyword'
637  and post_id = %d
638SQL;
639
640      // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,,WordPress.DB.PreparedSQL.NotPrepared
641      $keywords = $wpdb->get_results( $wpdb->prepare($keyword_sql, $post_id) );
642
643      if ( count( $keywords ) == 0 || strlen( $keywords[0]->focus_keywords ) == 0 ) {
644        return array();
645      }
646
647      return explode( ',', $keywords[0]->focus_keywords );
648    }
649
650    /**
651     * Return array of keywords from SEOPress.
652     *
653     * @since 1.0.31
654     * @access public
655     *
656     * @param integer $attachment_id ID of the attachment.
657     * @param integer $post_id ID of the post that has keywords. Can be NULL.
658     *
659     * @return Array of keywords, or empty array if none.
660     */
661    public function seopress_seo_keywords( $attachment_id, $post_id ) {
662      // Bail early if SEOPress is not active.
663      if ( ! ATAI_Utility::has_seopress() ) {
664        return array();
665      }
666
667      // Bail early if $post_id is null.
668      if ( ! $post_id ) {
669        return array();
670      }
671
672      global $wpdb;
673
674      $keyword_sql = <<<SQL
675select meta_value as focus_keywords
676from {$wpdb->postmeta}
677where meta_key = '_seopress_analysis_target_kw'
678  and post_id = %d
679SQL;
680
681      // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,,WordPress.DB.PreparedSQL.NotPrepared
682      $keywords = $wpdb->get_results( $wpdb->prepare($keyword_sql, $post_id) );
683
684      if ( count( $keywords ) == 0 || strlen( $keywords[0]->focus_keywords ) == 0 ) {
685        return array();
686      }
687
688      return explode( ',', $keywords[0]->focus_keywords );
689    }
690
691    /**
692     * Return array of keywords from Squirrly SEO.
693     *
694     * @since 1.0.36
695     * @access public
696     *
697     * @param integer $attachment_id ID of the attachment.
698     * @param integer $post_id ID of the post that has keywords. Can be NULL.
699     *
700     * @return Array of keywords, or empty array if none.
701     */
702    public function squirrly_seo_keywords( $attachment_id, $post_id ) {
703      // Bail early if Squirrly is not active.
704      if ( ! ATAI_Utility::has_squirrly() ) {
705        return array();
706      }
707
708      // Bail early if $post_id is null.
709      if ( ! $post_id ) {
710        return array();
711      }
712
713      global $wpdb;
714      $lookup_key = md5($post_id); // Squirrly uses a hash of the post ID as the key for their database table
715
716      $keyword_sql = <<<SQL
717select seo
718from {$wpdb->prefix}qss
719where url_hash = %s;
720SQL;
721
722      // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,,WordPress.DB.PreparedSQL.NotPrepared
723      $seo_data = $wpdb->get_results( $wpdb->prepare($keyword_sql, $lookup_key) );
724      if ( count( $seo_data ) == 0 || strlen( $seo_data[0]->seo ) == 0 ) {
725        return array();
726      }
727
728      $seo_data = unserialize($seo_data[0]->seo);
729      $keywords = $seo_data["keywords"];
730      return explode( ',', $keywords );
731    }
732
733    /**
734     * Return array of keywords from post title
735     *
736     * @since 1.0.36
737     * @access public
738     *
739     * @param integer $attachment_id ID of the attachment.
740     * @param integer $post_id ID of the post that has keywords. Can be NULL.
741     *
742     * @return Array of keywords, or empty array if none.
743     */
744    public function post_title_seo_keywords( $attachment_id ) {
745      global $wpdb;
746      $keyword_sql = <<<SQL
747select COALESCE(post_title, '') as title
748from {$wpdb->posts}
749where ID = (select post_parent from {$wpdb->posts} where ID = %d);
750SQL;
751
752      // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,,WordPress.DB.PreparedSQL.NotPrepared
753      $keyword_source = $wpdb->get_results( $wpdb->prepare($keyword_sql, $attachment_id) );
754      if ( count( $keyword_source ) == 0 || strlen( $keyword_source[0]->title ) == 0 ) {
755        return;
756      }
757
758      return $keyword_source[0]->title;
759    }
760
761    /**
762     * Return array of keywords from The SEO Framework plugin.
763     *
764     * @since 1.6.0
765     * @access public
766     *
767     * @param integer $attachment_id ID of the attachment.
768     * @param integer $post_id ID of the post that has keywords. Can be NULL.
769     *
770     * @return Array of keywords, or empty array if none.
771     */
772    public function theseoframework_seo_keywords( $attachment_id, $post_id ) {
773      // Bail early if plugin is not active.
774      if ( ! ATAI_Utility::has_theseoframework() ) {
775        return array();
776      }
777
778      // Bail early if $post_id is null.
779      if ( ! $post_id ) {
780        return array();
781      }
782
783      global $wpdb;
784
785      $keyword_sql = <<<SQL
786select meta_value as keyword_data
787from {$wpdb->postmeta}
788where meta_key = '_tsfem-extension-post-meta'
789  and post_id = %d
790SQL;
791
792      // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,,WordPress.DB.PreparedSQL.NotPrepared
793      $keyword_data = $wpdb->get_var( $wpdb->prepare($keyword_sql, $post_id) );
794      if ( empty($keyword_data) ) {
795        return array();
796      }
797
798      $keyword_data = unserialize(unserialize($keyword_data));
799
800      if ( empty($keyword_data['focus']) || empty($keyword_data['focus']['kw']) ) {
801        return array();
802      }
803
804      $keywords = array();
805      foreach ( $keyword_data['focus']['kw'] as $kw ) {
806        if ( !empty($kw['keyword']) ) {
807          array_push( $keywords, $kw['keyword'] );
808        }
809      }
810
811      return $keywords;
812    }
813 
814  /**
815   * Return array of keywords from SmartCrawl Pro.
816   *
817   * @since 1.9.91
818   * @access public
819   *
820   * @param integer $attachment_id ID of the attachment.
821   * @param integer $post_id ID of the post that has keywords. Can be NULL.
822   *
823   * @return Array of keywords, or empty array if none.
824   */
825  public function smartcrawl_seo_keywords($attachment_id, $post_id)
826  {
827    // Ensure SmartCrawl is active
828    if (! ATAI_Utility::has_smartcrawl()) {
829      return array();
830    }
831
832    // Bail if post ID is missing
833    if (! $post_id) {
834      return array();
835    }
836
837    // Fetch SmartCrawl focus keywords
838    $raw_focus_keywords = get_post_meta($post_id, '_wds_focus-keywords', true);
839
840    if (empty($raw_focus_keywords)) {
841      return array();
842    }
843
844    // Convert serialized data if needed
845    if (is_serialized($raw_focus_keywords)) {
846      $focus_keywords = unserialize($raw_focus_keywords);
847    } else {
848      $focus_keywords = explode(',', $raw_focus_keywords);
849    }
850
851    return array_map('trim', (array) $focus_keywords);
852  }
853
854  /**
855   * Generate alt text for newly added image/attachment
856   *
857   * @since 1.0.0
858   * @access public
859   *
860   * @param integer $attachment_id ID of the newly uploaded image/attachment
861   */
862  public function add_attachment( $attachment_id ) {
863    if ( get_option( 'atai_enabled' ) === 'no' ) {
864      return;
865    }
866
867    $this->generate_alt( $attachment_id );
868
869    // For WPML, we have to also generate the alt for the translated image attachments:
870    if ( !ATAI_Utility::has_wpml() ) { return; }
871
872    $active_languages = apply_filters( 'wpml_active_languages', NULL );
873    $language_codes = array_keys($active_languages);
874    foreach( $language_codes as $lang ) {
875      $translated_attachment_id = apply_filters( 'wpml_object_id', $attachment_id, 'attachment', FALSE, $lang );
876      if ( isset($translated_attachment_id) && ($translated_attachment_id != $attachment_id) ) {
877        $this->generate_alt( $translated_attachment_id );
878      }
879    }
880  }
881
882  /**
883   * Generate alt text in bulk
884   *
885   * @since 1.0.0
886   * @access public
887   */
888  public function ajax_bulk_generate() {
889    check_ajax_referer( 'atai_bulk_generate', 'security' );
890
891    // Check permissions
892    $this->check_attachment_permissions();
893
894    global $wpdb;
895    $post_id = intval($_REQUEST['post_id'] ?? 0);
896    $last_post_id = intval($_REQUEST['last_post_id'] ?? 0);
897    $query_limit = max( intval($_REQUEST['posts_per_page'] ?? 0), 1);
898    $keywords = is_array($_REQUEST['keywords'] ?? null) ? array_map('sanitize_text_field', $_REQUEST['keywords']) : [];
899    $negative_keywords = is_array($_REQUEST['negativeKeywords'] ?? null) ? array_map('sanitize_text_field', $_REQUEST['negativeKeywords']) : [];
900    $mode = sanitize_text_field( $_REQUEST['mode'] ?? 'missing' );
901    $only_attached = sanitize_text_field( $_REQUEST['onlyAttached'] ?? '0' );
902    $only_new = sanitize_text_field( $_REQUEST['onlyNew'] ?? '0' );
903    $wc_products = sanitize_text_field( $_REQUEST['wcProducts'] ?? '0' );
904    $wc_only_featured = sanitize_text_field( $_REQUEST['wcOnlyFeatured'] ?? '0' );
905    $batch_id = sanitize_text_field( $_REQUEST['batchId'] ?? '0' );
906    $images_successful  = $loop_count = 0;
907    $redirect_url = admin_url( 'admin.php?page=atai-bulk-generate' );
908    $recursive = true;
909
910    if ( $mode === 'all' ) {
911      $images_to_update_sql = <<<SQL
912SELECT p.ID as post_id
913FROM {$wpdb->posts} p
914WHERE p.ID > {$last_post_id}
915  AND (p.post_mime_type LIKE 'image/%')
916  AND p.post_type = 'attachment'
917  AND (p.post_status = 'inherit')
918SQL;
919    } else {
920      // Default to 'missing' mode
921      // Processes images that are missing alt text
922      $images_to_update_sql = <<<SQL
923SELECT p.ID as post_id
924FROM {$wpdb->posts} p
925    LEFT JOIN {$wpdb->postmeta} AS pm
926       ON (p.ID = pm.post_id AND pm.meta_key = '_wp_attachment_image_alt')
927    LEFT JOIN {$wpdb->postmeta} AS mt1 ON (p.ID = mt1.post_id)
928WHERE p.ID > {$last_post_id}
929  AND (p.post_mime_type LIKE 'image/%')
930  AND (pm.post_id IS NULL OR (mt1.meta_key = '_wp_attachment_image_alt' AND mt1.meta_value = ''))
931  AND p.post_type = 'attachment'
932  AND (p.post_status = 'inherit')
933SQL;
934    }
935
936    if ( $post_id ) {
937      $images_to_update_sql = $images_to_update_sql . " AND (p.post_parent = {$post_id})";
938    }
939    else {
940      if ( $only_attached === '1' ) {
941        $images_to_update_sql = $images_to_update_sql . " AND (p.post_parent > 0)";
942      }
943
944      if ( $only_new === '1' ) {
945        $atai_asset_table = $wpdb->prefix . ATAI_DB_ASSET_TABLE;
946        $images_to_update_sql = $images_to_update_sql . " AND (NOT EXISTS(SELECT 1 FROM {$atai_asset_table} WHERE wp_post_id = p.ID))";
947      }
948
949      if ($wc_products === '1') {
950        $images_to_update_sql = $images_to_update_sql . " AND (EXISTS(SELECT 1 FROM {$wpdb->posts} p2 WHERE p2.ID = p.post_parent and p2.post_type = 'product'))";
951      }
952
953      if ($wc_only_featured === '1') {
954        $images_to_update_sql = $images_to_update_sql . " AND (EXISTS(SELECT 1 FROM {$wpdb->postmeta} pm2 WHERE pm2.post_id = p.post_parent and pm2.meta_key = '_thumbnail_id' and CAST(pm2.meta_value as UNSIGNED) = p.ID))";
955      }
956    }
957
958    if ( $mode === 'bulk-select' ) {
959      $images_to_update = get_transient( 'alttext_bulk_select_generate_' . $batch_id );
960
961      if ( ! is_array( $images_to_update ) ) {
962        $images_to_update = [];
963      }
964
965      if ( $url = get_transient( 'alttext_bulk_select_generate_redirect_' . $batch_id ) ) {
966        $redirect_url = $url;
967      }
968    } else {
969      $images_to_update_sql = $images_to_update_sql . " GROUP BY p.ID ORDER BY p.ID LIMIT %d";
970      // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,,WordPress.DB.PreparedSQL.NotPrepared
971      $images_to_update = $wpdb->get_results( $wpdb->prepare( $images_to_update_sql, $query_limit) );
972    }
973
974    if ( count( $images_to_update ) == 0 ) {
975      wp_send_json( array(
976        'status' => 'success',
977        'message' => __( 'No images to process.', 'alttext-ai' ),
978        'process_count'   => 0,
979        'success_count'   => 0,
980        'last_post_id' => $last_post_id,
981        'recursive' => false,
982        'redirect_url' => $redirect_url,
983      ) );
984    }
985
986    foreach ( $images_to_update as &$image ) {
987      $attachment_id = ( $mode === 'bulk-select' ) ? $image : $image->post_id;
988      if ( defined( 'ATAI_BULK_DEBUG' ) ) {
989        ATAI_Utility::log_error( sprintf("BulkGenerate: Attachment ID %d", $attachment_id) );
990      }
991      $response = $this->generate_alt( $attachment_id, null, array( 'keywords' => $keywords, 'negative_keywords' => $negative_keywords ) );
992
993      if ( $response === 'insufficient_credits' ) {
994        wp_send_json( array(
995          'status'      => 'success',
996          'message'     => __( 'Images partially updated (no more credits).', 'alttext-ai' ),
997          'process_count'   => $loop_count,
998          'success_count'   => $images_successful,
999          'last_post_id' => $last_post_id,
1000          'recursive'   => false,
1001          'redirect_url' => $redirect_url,
1002        ) );
1003      }
1004
1005      $last_post_id = $attachment_id;
1006
1007      if ( ! is_array( $response ) && $response !== false ) {
1008        $images_successful++;
1009      }
1010
1011      if ( $mode === 'bulk-select' ) {
1012        // Remove the attachment ID from the transient
1013        $images_to_update = array_diff( $images_to_update, array( $attachment_id ) );
1014        set_transient( 'alttext_bulk_select_generate_' . $batch_id, $images_to_update, 2048 );
1015      }
1016
1017      if ( ++$loop_count >= $query_limit ) {
1018        break;
1019      }
1020    }
1021
1022    // Delete transients if all selected images are processed
1023    if ( $mode === 'bulk-select' && count( $images_to_update ) === 0 ) {
1024      delete_transient( 'alttext_bulk_select_generate_' . $batch_id );
1025      delete_transient( 'alttext_bulk_select_generate_redirect_' . $batch_id );
1026
1027      $recursive = false;
1028    }
1029
1030    wp_send_json( array(
1031      'status'          => 'success',
1032      'message'         => __( 'Images successfully updated.', 'alttext-ai' ),
1033      'process_count'   => $loop_count,
1034      'success_count'   => $images_successful,
1035      'last_post_id'    => $last_post_id,
1036      'recursive'       => $recursive,
1037      'redirect_url' => $redirect_url,
1038    ) );
1039  }
1040
1041  /**
1042   * Generate ALT text for a single image, based on URL-based parameters
1043   *
1044   * @since 1.0.10
1045   * @access public
1046   */
1047  public function action_single_generate() {
1048    // Bail early if action does not exist
1049    // or action is not relevant
1050    if ( ! isset( $_GET['atai_action'] ) || $_GET['atai_action'] !== 'generate' ) {
1051      return;
1052    }
1053
1054    $attachment_id  = isset( $_GET['item'] ) ? intval($_GET['item']) : 0;
1055
1056    if ( ! $attachment_id ) {
1057      $attachment_id  = isset( $_GET['post'] ) ? intval($_GET['post']) : 0;
1058    }
1059
1060    // Bail early if attachment ID is not valid
1061    if ( ! $attachment_id ) {
1062      return;
1063    }
1064
1065    // Generate ALT text
1066    $this->generate_alt( $attachment_id );
1067
1068    // Redirect back to edit page
1069    wp_safe_redirect( wp_get_referer() );
1070  }
1071
1072  /**
1073   * Generate ALT text for a single image, via AJAX
1074   *
1075   * @since 1.0.11
1076   * @access public
1077   */
1078  public function ajax_single_generate() {
1079    // Check permissions
1080    $this->check_attachment_permissions();
1081
1082    // Bail early if attachment ID does not exist, or ID is not numeric
1083    if ( ! isset( $_REQUEST['attachment_id'] ) || empty( $_REQUEST['attachment_id'] ) || ! is_numeric( $_REQUEST['attachment_id'] ) ) {
1084      return;
1085    }
1086
1087    $attachment_id = sanitize_text_field( $_REQUEST['attachment_id'] );
1088    $keywords = is_array($_REQUEST['keywords']) ? array_map('sanitize_text_field', $_REQUEST['keywords']) : [];
1089
1090    // Generate ALT text
1091    $response = $this->generate_alt( $attachment_id, null, array( 'keywords' => $keywords ) );
1092
1093    if ( $response === 'insufficient_credits' ) {
1094      wp_send_json( array(
1095        'status' => 'error',
1096        'message' => 'You have no more credits available. Go to your account on AltText.ai to get more credits.',
1097      ) );
1098    }
1099
1100    if ( ! is_array( $response ) && $response !== false ) {
1101      wp_send_json( array(
1102        'status' => 'success',
1103        'alt_text' => $response,
1104      ) );
1105    }
1106
1107    wp_send_json( array(
1108      'status' => 'error',
1109    ) );
1110  }
1111
1112  /**
1113   * Update ALT text via AJAX
1114   *
1115   * @since 1.4.4
1116   * @access public
1117   */
1118  public function ajax_edit_history() {
1119    check_ajax_referer( 'atai_edit_history', 'security' );
1120   
1121    // Check permissions
1122    $this->check_attachment_permissions();
1123
1124    $attachment_id = intval( $_REQUEST['attachment_id'] ?? 0 );
1125    $alt_text = sanitize_text_field( $_REQUEST['alt_text'] ?? '' );
1126
1127    if ( ! $attachment_id ) {
1128      wp_send_json( array(
1129        'status' => 'error',
1130        'message' => __( 'Invalid request.', 'alttext-ai' )
1131      ) );
1132    }
1133
1134    update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt_text );
1135
1136    wp_send_json( array(
1137      'status' => 'success',
1138      'message' => __( 'Alt text updated.', 'alttext-ai' )
1139    ) );
1140  }
1141
1142  /**
1143   * Check if the current user has permission to manage attachments
1144   *
1145   * @since 1.9.94
1146   * @access private
1147   * @return bool|void Returns true if user has permission, otherwise sends JSON error response and exits
1148   */
1149  private function check_attachment_permissions() {
1150    if ( ! current_user_can( 'upload_files' ) ) {
1151      wp_send_json( array(
1152        'status' => 'error',
1153        'message' => __( 'You do not have permission to manage attachments.', 'alttext-ai' )
1154      ) );
1155    }
1156    return true;
1157  }
1158
1159  /**
1160   * Check if attachment is eligible for auto-generating ALT text via AJAX
1161   *
1162   * @since 1.0.10
1163   * @access public
1164   */
1165  public function ajax_check_attachment_eligibility() {
1166    check_ajax_referer( 'atai_check_attachment_eligibility', 'security' );
1167
1168    // Check permissions
1169    $this->check_attachment_permissions();
1170
1171    $attachment_id =  intval( $_POST['attachment_id'] ?? 0 );
1172
1173    // Bail early if post ID is not valid
1174    if ( ! $attachment_id ) {
1175      wp_send_json( array(
1176        'status' => 'error',
1177        'message' => __( 'Invalid post ID.', 'alttext-ai' )
1178      ) );
1179    }
1180
1181    if ( ! $this->is_attachment_eligible( $attachment_id, 'check' ) ) {
1182      wp_send_json( array(
1183        'status' => 'error',
1184        'message' => __( 'Image is not eligible for auto-generating ALT text.', 'alttext-ai' )
1185      ) );
1186    }
1187
1188    wp_send_json( array(
1189      'status' => 'success',
1190      'message' => __( 'Image is eligible for auto-generating ALT text.', 'alttext-ai' )
1191    ) );
1192  }
1193
1194  /**
1195   * Add Generate ALT Text option to bulk actions
1196   *
1197   * @since 1.0.27
1198   * @access public
1199   *
1200   * @param Array $actions Array of bulk actions.
1201   */
1202  public function add_bulk_select_action( $actions ) {
1203    $actions[ 'alttext_options' ] = __( '&#8595; AltText.ai', 'alttext-ai' );
1204    $actions[ 'alttext_generate_alt' ] = __( 'Generate Alt Text', 'alttext-ai' );
1205    return $actions;
1206  }
1207
1208  /**
1209   * Process bulk select action
1210   *
1211   * @since 1.0.27
1212   * @access public
1213   *
1214   * @param String $redirect URL to redirect to after processing.
1215   * @param String $do_action The action being taken.
1216   * @param Array $items Array of attachments/images multi-selected to take action on.
1217   *
1218   * @return String $redirect URL to redirect to.
1219   */
1220  public function bulk_select_action_handler( $redirect, $do_action, $items ) {
1221    // Bail early if action is not alttext_generate_alt
1222    if ( $do_action !== 'alttext_generate_alt' ) {
1223      return $redirect;
1224    }
1225
1226    // Generate a random id to identify the bulk action request
1227    $batch_id = uniqid();
1228
1229    // Store the attachment IDs in a transient
1230    set_transient( 'alttext_bulk_select_generate_' . $batch_id, $items, 2048 );
1231
1232    // Store the redirect URL in a transient
1233    set_transient( 'alttext_bulk_select_generate_redirect_' . $batch_id, $redirect, 2048 );
1234
1235    // Redirect to the bulk action handler
1236    return admin_url( 'admin.php?page=atai-bulk-generate&atai_action=bulk-select-generate&atai_batch_id=' . $batch_id );
1237  }
1238
1239  /**
1240   * Render bulk select notice
1241   *
1242   * @since 1.0.27
1243   * @access public
1244   */
1245  public function render_bulk_select_notice() {
1246    // Get the count of images that were processed
1247    $count = get_transient( 'bulk_generate_alt' );
1248
1249    // Bail early if no bulk generate alt action was done
1250    if ( $count === false ) {
1251      return;
1252    }
1253
1254    // Construct the notice message
1255    $message = sprintf(
1256      "[AltText.ai] Finished generating alt text for %d %s.",
1257      $count,
1258      _n(
1259        'image',
1260        'images',
1261        $count
1262      )
1263    );
1264
1265    // Display the notice
1266    echo "<div class=\"notice notice-success is-dismissible\"><p>", esc_html($message), "</p></div>";
1267
1268    // Delete the transient
1269    delete_transient( 'bulk_generate_alt' );
1270  }
1271
1272  /**
1273   * Process a new translation of an attachment from Polylang.
1274   *
1275   * @since 1.0.34
1276   * @access public
1277   *
1278   * @param Int $post_id The ID of the source post that was translated.
1279   * @param Int $tr_id The ID of the new translated post.
1280   * @param String $lang_slug Language code of the new translation.
1281   *
1282   */
1283  public function on_translation_created( $post_id, $tr_id, $lang_slug ) {
1284    $post = get_post($post_id);
1285    if (!isset($post)) {
1286      return;
1287    }
1288
1289    // Bail early unless we have an image
1290    if ($post->post_type != "attachment" || $post->post_status != "inherit" || (0 != substr_compare($post->post_mime_type, "image", 0, 5))) {
1291      return;
1292    }
1293
1294    $this->add_attachment($tr_id);
1295  }
1296
1297  /**
1298   * Processes the uploaded CSV file to import ALT text for attachments.
1299   *
1300   * This method handles the CSV file upload, validates the file structure,
1301   * and updates the ALT text of the corresponding attachments in the WordPress
1302   * database. The CSV file should contain columns 'asset_id' and 'alt_text'.
1303   *
1304   * @since 1.1.0
1305   * @access public
1306   *
1307   * @return array Associative array containing the status and message of the operation.
1308   *               Returns 'success' status and a success message on successful import.
1309   *               Returns 'error' status and an error message if any issue occurs.
1310   */
1311  public function process_csv() {
1312    $uploaded_file = $_FILES['csv'] ?? []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1313    $moved_file = wp_handle_upload( $uploaded_file, array( 'test_form' => false ) );
1314
1315    // Bail early if file upload failed
1316    if ( ! $moved_file || isset( $moved_file['error'] ) ) {
1317      return array(
1318        'status' => 'error',
1319        'message' => $moved_file['error']
1320      );
1321    }
1322
1323    $images_updated = 0;
1324    $filename = $moved_file['file'];
1325    $handle = fopen( $filename, "r" );
1326
1327    // Read the first row as header
1328    $header = fgetcsv( $handle, ATAI_CSV_LINE_LENGTH, ',' );
1329
1330    // Check if the required columns exist and capture their indexes
1331    $asset_id_index = array_search( 'asset_id', $header );
1332    $image_url_index = array_search( 'url', $header );
1333    $alt_text_index = array_search( 'alt_text', $header );
1334
1335    // Bail early if required columns do not exist
1336    if ( $asset_id_index === false || $alt_text_index === false ) {
1337      fclose( $handle );
1338      unlink( $filename );
1339
1340      return array(
1341        'status' => 'error',
1342        'message' => __( 'Invalid CSV file. Please make sure the file has the required columns.', 'alttext-ai' )
1343      );
1344    }
1345
1346    // Loop through the rest of the rows and use the captured indexes to get the values
1347    while ( ( $data = fgetcsv( $handle, 1000, ',' ) ) !== FALSE ) {
1348      global $wpdb;
1349
1350      $asset_id = $data[$asset_id_index];
1351      $alt_text = $data[$alt_text_index];
1352
1353      // Get the attachment ID from the asset ID
1354      $attachment_id = ATAI_Utility::find_atai_asset($asset_id);
1355
1356      if ( ! $attachment_id && $image_url_index !== false ) {
1357        // If we don't have the attachment ID, try to get it from the URL
1358        $image_url = $data[$image_url_index];
1359        $attachment_id = attachment_url_to_postid( $image_url );
1360
1361        if ( !empty($attachment_id) ) {
1362          ATAI_Utility::record_atai_asset($attachment_id, $asset_id);
1363        }
1364      }
1365
1366      if ( ! $attachment_id ) {
1367        // If we still don't have the attachment ID, skip this row
1368        continue;
1369      }
1370
1371      // Update the ALT text
1372      update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt_text );
1373      $images_updated++;
1374
1375      if ( empty( $alt_text ) ) {
1376        // Do not clear other values if alt text was empty
1377        continue;
1378      }
1379
1380      // Update the post title, caption, and description if the corresponding option is enabled
1381      $post_value_updates = array();
1382
1383      if ( get_option( 'atai_update_title' ) === 'yes' ) {
1384        $post_value_updates['post_title'] = $alt_text;
1385      };
1386
1387      if ( get_option( 'atai_update_caption' ) === 'yes' ) {
1388        $post_value_updates['post_excerpt'] = $alt_text;
1389      };
1390
1391      if ( get_option( 'atai_update_description' ) === 'yes' ) {
1392        $post_value_updates['post_content'] = $alt_text;
1393      };
1394
1395      if ( ! empty( $post_value_updates ) ) {
1396        $post_value_updates['ID'] = $attachment_id;
1397        wp_update_post( $post_value_updates );
1398      };
1399    }
1400
1401    fclose( $handle );
1402    unlink( $filename );
1403
1404    $message = __( '[AltText.ai] No images were matched.', 'alttext-ai' );
1405
1406    if ( $images_updated ) {
1407      $message = sprintf(
1408        _n(
1409          '[AltText.ai] Successfully imported alt text for %d image.',
1410          '[AltText.ai] Successfully imported alt text for %d images.',
1411          $images_updated,
1412          'alttext-ai'
1413        ),
1414        $images_updated
1415      );
1416    }
1417
1418    return array(
1419      'status' => 'success',
1420      'message' => $message
1421    );
1422  }
1423
1424  /**
1425   * Add a filter to the media library to filter images by ALT text presence
1426   *
1427   * @since 1.3.5
1428   * @access public
1429   */
1430  public function add_media_alt_filter( $post_type ) {
1431    if ( $post_type !== 'attachment' ) {
1432      return;
1433    };
1434
1435    $atai_filter = sanitize_text_field( $_GET['atai_filter'] ?? 'all' );
1436
1437    echo '<select id="filter-by-alt" name="atai_filter">';
1438    echo '<option value="all" ' . selected( $atai_filter, 'all', false ) . '>' . esc_html__( 'Any alt text', 'alttext-ai' ) . '</option>';
1439    echo '<option value="missing" ' . selected( $atai_filter, 'missing', false ) . '>' . esc_html__( 'Without alt text', 'alttext-ai' ) . '</option>';
1440    echo '</select>';
1441  }
1442
1443  /**
1444   * Filter the media library query to show only images missing ALT text
1445   *
1446   * @since 1.3.5
1447   * @access public
1448   */
1449  public function media_alt_filter_handler( $query ) {
1450    $is_media_screen = false;
1451
1452    if ( function_exists( 'get_current_screen' ) && get_current_screen() ) {
1453      $is_media_screen = get_current_screen()->base === 'upload';
1454    }
1455    else {
1456      $is_media_screen = ( isset($query->query['post_type'] ) && ( $query->query['post_type'] === 'attachment' ) );
1457    }
1458
1459    $atai_filter = sanitize_text_field( $_GET['atai_filter'] ?? 'all' );
1460
1461    if ( ! is_admin() || ! $query->is_main_query() || ! $is_media_screen || $atai_filter === 'all' ) {
1462      return;
1463    }
1464
1465    $meta_query = $query->get('meta_query') ? $query->get('meta_query') : array();
1466
1467    $meta_query[] = array(
1468      'relation' => 'OR',
1469      array(
1470        'key' => '_wp_attachment_image_alt',
1471        'compare' => 'NOT EXISTS',
1472      ),
1473      array(
1474        'key' => '_wp_attachment_image_alt',
1475        'value' => '',
1476        'compare' => '=',
1477      ),
1478    );
1479
1480    $query->set( 'meta_query', $meta_query );
1481  }
1482}
Note: See TracBrowser for help on using the repository browser.