Plugin Directory

Changeset 3466692


Ignore:
Timestamp:
02/22/2026 03:54:25 AM (2 weeks ago)
Author:
alttextai
Message:

Update to version 1.10.25 - Network Multisite Bulk Generate and WP-CLI enrich/import commands

Location:
alttext-ai/trunk
Files:
12 edited

Legend:

Unmodified
Added
Removed
  • alttext-ai/trunk/README.txt

    r3463711 r3466692  
    66Requires at least: 4.7
    77Tested up to: 6.9
    8 Stable tag: 1.10.22
     8Stable tag: 1.10.25
    99WC requires at least: 3.3
    1010WC tested up to: 10.1
     
    7171
    7272== Changelog ==
     73
     74= 1.10.25 - 2026-02-20 =
     75* NEW: Network Bulk Generate for WordPress Multisite — manage alt text across all your subsites from one central Network Admin dashboard
     76* NEW: WP-CLI `wp alttext enrich` command — generate alt text for images embedded in posts and pages directly from the command line
     77* NEW: WP-CLI `wp alttext import` command — import alt text in bulk from AltText.ai CSV exports via the command line
     78* Fixed: Alt text updates in the Media Library now sync correctly into Elementor image modals without requiring images to be re-added
    7379
    7480= 1.10.22 - 2026-02-17 =
  • alttext-ai/trunk/admin/class-atai-settings.php

    r3450493 r3466692  
    181181      wp_enqueue_style( 'atai-admin', plugin_dir_url( __FILE__ ) . 'css/admin.css', array(), $this->version, 'all' );
    182182    }
     183  }
     184
     185  /**
     186   * Register the network bulk generate page.
     187   *
     188   * @since    1.10.20
     189   * @access   public
     190   */
     191  public function register_network_bulk_generate_page() {
     192    if ( ! is_multisite() ) {
     193      return;
     194    }
     195
     196    add_submenu_page(
     197      'settings.php',
     198      __( 'AltText.ai Network Bulk Generate', 'alttext-ai' ),
     199      __( 'AltText.ai Bulk Generate', 'alttext-ai' ),
     200      'manage_network_options',
     201      'atai-network-bulk-generate',
     202      array( $this, 'render_network_bulk_generate_page' )
     203    );
     204
     205    add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_network_bulk_generate_scripts' ) );
     206  }
     207
     208  /**
     209   * Render the network bulk generate page.
     210   *
     211   * @since    1.10.20
     212   * @access   public
     213   */
     214  public function render_network_bulk_generate_page() {
     215    $this->load_account();
     216    require_once plugin_dir_path( dirname( __FILE__ ) ) . 'admin/partials/network-bulk-generate.php';
     217  }
     218
     219  /**
     220   * Enqueue scripts for the network bulk generate page.
     221   *
     222   * @since    1.10.20
     223   */
     224  public function enqueue_network_bulk_generate_scripts( $hook ) {
     225    if ( strpos( $hook, 'atai-network-bulk-generate' ) === false ) {
     226      return;
     227    }
     228    wp_enqueue_style( 'atai-admin', plugin_dir_url( __FILE__ ) . 'css/admin.css', array(), $this->version, 'all' );
     229    wp_enqueue_script( 'atai-network-admin', plugin_dir_url( __FILE__ ) . 'js/network-admin.js', array( 'jquery' ), $this->version, true );
     230    wp_localize_script( 'atai-network-admin', 'wp_atai_network', array(
     231      'ajax_url' => admin_url( 'admin-ajax.php' ),
     232      'security' => wp_create_nonce( 'atai_network_bulk_generate' ),
     233    ) );
    183234  }
    184235
     
    862913   */
    863914  public function display_insufficient_credits_notice() {
     915    // On subsites where the network admin has hidden credit information, suppress this notice
     916    if ( is_multisite() && ! is_main_site() && get_site_option( 'atai_network_hide_credits' ) === 'yes' ) {
     917      return;
     918    }
     919
    864920    // Bail early if notice transient is not set
    865921    if ( ! get_transient( 'atai_insufficient_credits' ) ) {
  • alttext-ai/trunk/admin/css/admin.css

    r3466688 r3466692  
    17011701}
    17021702
     1703.atai-settings-footer {
     1704  display: flex;
     1705  align-items: baseline;
     1706  justify-content: space-between;
     1707  margin-top: 1rem;
     1708}
     1709
     1710.atai-version {
     1711  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
     1712  font-size: 0.75rem;
     1713  color: #4b5563;
     1714  letter-spacing: 0.05em;
     1715  text-transform: uppercase;
     1716  font-weight: 600;
     1717}
     1718
     1719.atai-status-error {
     1720  color: #d63638;
     1721}
     1722
     1723.atai-status-success {
     1724  color: #007a1f;
     1725}
     1726
     1727.atai-status-processing {
     1728  color: #2271b1;
     1729}
     1730
     1731
     1732/* Prevent WP admin footer from overlapping plugin page content.
     1733   admin.css is only enqueued on this plugin's pages, so this selector
     1734   is safe to use globally here. */
     1735#wpbody-content {
     1736  padding-bottom: 80px;
     1737}
  • alttext-ai/trunk/admin/partials/settings.php

    r3455483 r3466692  
    828828    </div>
    829829
    830     <?php if ( ! $settings_network_controlled ) : ?>
    831       <input type="submit" name="submit" value="Save Changes" class="atai-button blue mt-4 cursor-pointer appearance-none no-underline shadow-sm">
    832     <?php endif; ?>
     830    <div class="atai-settings-footer">
     831      <?php if ( ! $settings_network_controlled ) : ?>
     832        <input type="submit" name="submit" value="Save Changes" class="atai-button blue cursor-pointer appearance-none no-underline shadow-sm">
     833      <?php endif; ?>
     834      <span class="atai-version">v<?php echo esc_html( ATAI_VERSION ); ?></span>
     835    </div>
    833836  </form>
    834837</div>
  • alttext-ai/trunk/atai.php

    r3463711 r3466692  
    1616 * Plugin URI:        https://alttext.ai/product
    1717 * Description:       Automatically generate image alt text with AltText.ai.
    18  * Version:           1.10.22
     18 * Version:           1.10.25
    1919 * Author:            AltText.ai
    2020 * Author URI:        https://alttext.ai
     
    3131}
    3232
     33
    3334/**
    3435 * Current plugin version.
    3536 */
    36 define( 'ATAI_VERSION', '1.10.22' );
     37define( 'ATAI_VERSION', '1.10.25' );
    3738
    3839/**
  • alttext-ai/trunk/changelog.txt

    r3463711 r3466692  
    11*** AltText.ai Changelog ***
     2
     32026-02-20 - version 1.10.25
     4* NEW: Network Bulk Generate for WordPress Multisite — manage alt text across all your subsites from one central Network Admin dashboard
     5* NEW: WP-CLI `wp alttext enrich` command — generate alt text for images embedded in posts and pages directly from the command line
     6* NEW: WP-CLI `wp alttext import` command — import alt text in bulk from AltText.ai CSV exports via the command line
     7* Fixed: Alt text updates in the Media Library now sync correctly into Elementor image modals without requiring images to be re-added
    28
    392026-02-17 - version 1.10.22
  • alttext-ai/trunk/includes/class-atai-api.php

    r3453350 r3466692  
    144144        $file_contents = @file_get_contents( $file_path );
    145145        if ( $file_contents === false ) {
     146          // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log, QITStandard.PHP.DebugCode.DebugFunctionFound -- Production error logging
    146147          error_log( "ATAI: Failed to read file for attachment {$attachment_id}" );
    147148          return false;
     
    150151        $encoded_content = @base64_encode( $file_contents );
    151152        if ( $encoded_content === false ) {
     153          // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log, QITStandard.PHP.DebugCode.DebugFunctionFound -- Production error logging
    152154          error_log( "ATAI: Failed to encode file for attachment {$attachment_id}" );
    153155          return false;
  • alttext-ai/trunk/includes/class-atai-attachment.php

    r3455483 r3466692  
    23442344    $query->set( 'meta_query', $meta_query );
    23452345  }
     2346
     2347
     2348  /**
     2349   * Get image stats across all network sites.
     2350   *
     2351   * @since 1.10.20
     2352   * @access public
     2353   */
     2354  public function ajax_network_get_stats() {
     2355    check_ajax_referer( 'atai_network_bulk_generate', 'security' );
     2356
     2357    if ( ! current_user_can( 'manage_network_options' ) ) {
     2358      wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'alttext-ai' ) ) );
     2359    }
     2360
     2361    $stats = array();
     2362    $offset = 0;
     2363    $batch_size = 200;
     2364
     2365    do {
     2366      $sites = get_sites(
     2367        array(
     2368          'number' => $batch_size,
     2369          'offset' => $offset,
     2370        )
     2371      );
     2372
     2373      foreach ( $sites as $site ) {
     2374        switch_to_blog( $site->blog_id );
     2375
     2376        try {
     2377          global $wpdb;
     2378
     2379          // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
     2380          $total = (int) $wpdb->get_var(
     2381            "SELECT COUNT(*)
     2382             FROM {$wpdb->posts}
     2383             WHERE post_mime_type LIKE 'image/%'
     2384               AND post_type = 'attachment'
     2385               AND post_status = 'inherit'"
     2386          );
     2387
     2388          // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
     2389          $missing = (int) $wpdb->get_var(
     2390            "SELECT COUNT(*)
     2391             FROM {$wpdb->posts} p
     2392             LEFT JOIN {$wpdb->postmeta} pm
     2393               ON p.ID = pm.post_id AND pm.meta_key = '_wp_attachment_image_alt'
     2394             WHERE p.post_mime_type LIKE 'image/%'
     2395               AND p.post_type = 'attachment'
     2396               AND p.post_status = 'inherit'
     2397               AND (pm.post_id IS NULL OR TRIM(COALESCE(pm.meta_value, '')) = '')"
     2398          );
     2399
     2400          $stats[] = array(
     2401            'blog_id'      => (int) $site->blog_id,
     2402            'name'         => get_bloginfo( 'name' ),
     2403            'url'          => get_bloginfo( 'url' ),
     2404            'total_images' => $total,
     2405            'missing_alt'  => $missing,
     2406          );
     2407        } catch ( \Exception $e ) {
     2408          // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log, QITStandard.PHP.DebugCode.DebugFunctionFound -- Production error logging
     2409          error_log( 'ATAI network stats: site ' . $site->blog_id . ' – ' . $e->getMessage() );
     2410          $stats[] = array(
     2411            'blog_id'      => (int) $site->blog_id,
     2412            'name'         => get_bloginfo( 'name' ),
     2413            'url'          => get_bloginfo( 'url' ),
     2414            'total_images' => 0,
     2415            'missing_alt'  => 0,
     2416            'error'        => true,
     2417          );
     2418        }
     2419
     2420        restore_current_blog();
     2421      }
     2422
     2423      $offset += count( $sites );
     2424    } while ( count( $sites ) === $batch_size );
     2425
     2426    wp_send_json_success( array( 'sites' => $stats ) );
     2427  }
     2428
     2429
     2430  /**
     2431   * Bulk generate alt text for a specific network site.
     2432   *
     2433   * @since 1.10.20
     2434   * @access public
     2435   */
     2436  public function ajax_network_bulk_generate() {
     2437    check_ajax_referer( 'atai_network_bulk_generate', 'security' );
     2438
     2439    if ( ! current_user_can( 'manage_network_options' ) ) {
     2440      wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'alttext-ai' ) ) );
     2441    }
     2442
     2443    $blog_id = absint( $_REQUEST['blog_id'] ?? 0 );
     2444    if ( ! $blog_id || ! get_blog_details( $blog_id ) ) {
     2445      wp_send_json_error( array( 'message' => __( 'Invalid site.', 'alttext-ai' ) ) );
     2446    }
     2447
     2448    switch_to_blog( $blog_id );
     2449
     2450    try {
     2451      global $wpdb;
     2452      $last_post_id = absint( $_REQUEST['last_post_id'] ?? 0 );
     2453      $query_limit  = min( max( absint( $_REQUEST['posts_per_page'] ?? 0 ), 1 ), 5 );
     2454
     2455      // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
     2456      $images = $wpdb->get_results(
     2457        $wpdb->prepare(
     2458          "SELECT p.ID as post_id
     2459           FROM {$wpdb->posts} p
     2460           LEFT JOIN {$wpdb->postmeta} AS pm
     2461             ON (p.ID = pm.post_id AND pm.meta_key = '_wp_attachment_image_alt')
     2462           WHERE p.ID > %d
     2463             AND (p.post_mime_type LIKE %s)
     2464             AND (pm.post_id IS NULL OR TRIM(COALESCE(pm.meta_value, '')) = '')
     2465             AND p.post_type = 'attachment'
     2466             AND (p.post_status = 'inherit')
     2467           GROUP BY p.ID ORDER BY p.ID LIMIT %d",
     2468          $last_post_id,
     2469          $wpdb->esc_like( 'image/' ) . '%',
     2470          $query_limit
     2471        )
     2472      );
     2473
     2474      if ( null === $images || $wpdb->last_error ) {
     2475        restore_current_blog();
     2476        wp_send_json_error( array( 'message' => __( 'Database query failed.', 'alttext-ai' ) ) );
     2477      }
     2478
     2479      $images_successful = 0;
     2480      $loop_count        = 0;
     2481
     2482      if ( empty( $images ) ) {
     2483        restore_current_blog();
     2484        wp_send_json_success( array(
     2485          'message'       => __( 'Site complete.', 'alttext-ai' ),
     2486          'process_count' => 0,
     2487          'success_count' => 0,
     2488          'last_post_id'  => $last_post_id,
     2489          'recursive'     => false,
     2490          'blog_id'       => $blog_id,
     2491        ) );
     2492      }
     2493
     2494      foreach ( $images as $image ) {
     2495        $attachment_id = absint( $image->post_id );
     2496
     2497        if ( ! $this->is_attachment_eligible( $attachment_id, 'network-bulk' ) ) {
     2498          $last_post_id = $attachment_id;
     2499          if ( ++$loop_count >= $query_limit ) {
     2500            break;
     2501          }
     2502          continue;
     2503        }
     2504
     2505        $response = $this->generate_alt( $attachment_id );
     2506
     2507        if ( $response === 'insufficient_credits' ) {
     2508          restore_current_blog();
     2509          wp_send_json_success( array(
     2510            'stop_reason'   => 'no_credits',
     2511            'message'       => __( 'No more credits.', 'alttext-ai' ),
     2512            'process_count' => $loop_count,
     2513            'success_count' => $images_successful,
     2514            'last_post_id'  => $last_post_id,
     2515            'recursive'     => false,
     2516            'blog_id'       => $blog_id,
     2517          ) );
     2518        }
     2519
     2520        $last_post_id = $attachment_id;
     2521
     2522        if ( is_string( $response ) && ! empty( $response ) && $response !== 'url_access_error' ) {
     2523          $images_successful++;
     2524        }
     2525
     2526        if ( ++$loop_count >= $query_limit ) {
     2527          break;
     2528        }
     2529      }
     2530    } catch ( \Exception $e ) {
     2531      // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log, QITStandard.PHP.DebugCode.DebugFunctionFound -- Production error logging
     2532      error_log( 'ATAI network bulk generate: blog ' . $blog_id . ' – ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
     2533      restore_current_blog();
     2534      wp_send_json_error( array( 'message' => __( 'An unexpected error occurred.', 'alttext-ai' ) ) );
     2535    }
     2536
     2537    restore_current_blog();
     2538
     2539    wp_send_json_success( array(
     2540      'process_count' => $loop_count,
     2541      'success_count' => $images_successful,
     2542      'last_post_id'  => $last_post_id,
     2543      'recursive'     => true,
     2544      'blog_id'       => $blog_id,
     2545    ) );
     2546  }
    23462547}
  • alttext-ai/trunk/includes/class-atai-cli.php

    r3455483 r3466692  
    44 *
    55 * @link       https://alttext.ai
    6  * @since      1.11.0
     6 * @since      1.10.23
    77 *
    88 * @package    ATAI
     
    3232 *     wp alttext status
    3333 *
    34  * @since 1.11.0
     34 *     # Enrich post content with alt text for inline images
     35 *     wp alttext enrich
     36 *
     37 *     # Enrich only WooCommerce products
     38 *     wp alttext enrich --post-type=product
     39 *
     40 *     # Import alt text from CSV file
     41 *     wp alttext import /path/to/export.csv
     42 *
     43 * @since 1.10.23
    3544 */
    3645class ATAI_CLI_Command {
     
    8695        $batch_size = max( 1, $batch_size );
    8796
    88         // Verify API key is configured.
    89         $api_key = ATAI_Utility::get_api_key();
    90         if ( empty( $api_key ) ) {
    91             WP_CLI::error( 'No API key configured. Set it in WordPress Admin → AltText.ai → Settings, or define ATAI_API_KEY constant.' );
    92         }
     97        // Treat --limit=0 as explicit no-op.
     98        if ( 0 === $limit ) {
     99            if ( $porcelain ) {
     100                WP_CLI::line( '0' );
     101            } else {
     102                WP_CLI::success( 'Nothing to process (--limit=0).' );
     103            }
     104            return;
     105        }
     106
     107        $this->require_api_key();
    93108
    94109        // Get eligible images.
     
    165180                    $failed++;
    166181                    if ( ! $porcelain ) {
    167                         WP_CLI::debug( sprintf( 'Failed to process attachment #%d', $attachment_id ) );
     182                        WP_CLI::warning( sprintf( 'Failed to process attachment #%d', $attachment_id ) );
    168183                    }
    169184                }
     
    380395
    381396    /**
     397     * Enrich post content with alt text for inline images.
     398     *
     399     * Scans published posts for <img> tags and generates alt text via the
     400     * AltText.ai API. Updates alt text directly in post content HTML.
     401     *
     402     * ## OPTIONS
     403     *
     404     * [--post-type=<type>]
     405     * : Comma-separated post types to process. Default: post,page (and product if WooCommerce active).
     406     *
     407     * [--limit=<number>]
     408     * : Maximum number of posts to process. Default: all.
     409     *
     410     * [--force]
     411     * : Regenerate alt text even for images that already have it.
     412     *
     413     * [--include-external]
     414     * : Also process external (non-library) images.
     415     *
     416     * [--dry-run]
     417     * : Show what would be processed without making changes.
     418     *
     419     * [--porcelain]
     420     * : Output only the count of generated alt texts (for scripting).
     421     *
     422     * ## EXAMPLES
     423     *
     424     *     # Enrich all posts and pages
     425     *     wp alttext enrich
     426     *
     427     *     # Enrich only WooCommerce products
     428     *     wp alttext enrich --post-type=product
     429     *
     430     *     # Preview what would be enriched
     431     *     wp alttext enrich --dry-run
     432     *
     433     *     # Overwrite existing alt text
     434     *     wp alttext enrich --force
     435     *
     436     * @when after_wp_load
     437     *
     438     * @param array $args       Positional arguments.
     439     * @param array $assoc_args Associative arguments.
     440     */
     441    public function enrich( $args, $assoc_args ) {
     442        $force            = isset( $assoc_args['force'] );
     443        $include_external = isset( $assoc_args['include-external'] );
     444        $dry_run          = isset( $assoc_args['dry-run'] );
     445        $porcelain        = isset( $assoc_args['porcelain'] );
     446        $limit            = isset( $assoc_args['limit'] ) ? absint( $assoc_args['limit'] ) : -1;
     447
     448        // Treat --limit=0 as explicit no-op.
     449        if ( 0 === $limit ) {
     450            if ( $porcelain ) {
     451                WP_CLI::line( '0' );
     452            } else {
     453                WP_CLI::success( 'Nothing to process (--limit=0).' );
     454            }
     455            return;
     456        }
     457
     458        // Build post types list.
     459        if ( isset( $assoc_args['post-type'] ) ) {
     460            $post_types = array_map( 'sanitize_key', array_map( 'trim', explode( ',', $assoc_args['post-type'] ) ) );
     461
     462            // Validate against registered post types.
     463            $registered = get_post_types();
     464            foreach ( $post_types as $pt ) {
     465                if ( ! isset( $registered[ $pt ] ) ) {
     466                    WP_CLI::warning( sprintf( 'Post type "%s" is not registered and will be skipped.', $pt ) );
     467                }
     468            }
     469            $post_types = array_filter( $post_types, function ( $pt ) use ( $registered ) {
     470                return isset( $registered[ $pt ] );
     471            } );
     472            if ( empty( $post_types ) ) {
     473                WP_CLI::error( 'No valid post types provided.' );
     474            }
     475        } else {
     476            $post_types = array( 'post', 'page' );
     477            if ( ATAI_Utility::has_woocommerce() ) {
     478                $post_types[] = 'product';
     479            }
     480        }
     481
     482        $this->require_api_key();
     483
     484        // Query posts.
     485        if ( ! $porcelain ) {
     486            WP_CLI::log( sprintf( 'Scanning %s for posts to enrich...', implode( ', ', $post_types ) ) );
     487        }
     488
     489        $post_ids = $this->get_posts_for_enrichment( $post_types, $limit );
     490
     491        if ( empty( $post_ids ) ) {
     492            if ( $porcelain ) {
     493                WP_CLI::line( '0' );
     494            } else {
     495                WP_CLI::success( 'No posts found to enrich.' );
     496            }
     497            return;
     498        }
     499
     500        $total = count( $post_ids );
     501
     502        if ( $dry_run ) {
     503            if ( $porcelain ) {
     504                WP_CLI::line( (string) $total );
     505            } else {
     506                WP_CLI::log( sprintf( 'Dry run: Would enrich %d posts.', $total ) );
     507                foreach ( array_slice( $post_ids, 0, 10 ) as $id ) {
     508                    $post = get_post( $id );
     509                    if ( ! $post ) {
     510                        WP_CLI::log( sprintf( '  - #%d: (post not found)', $id ) );
     511                        continue;
     512                    }
     513                    WP_CLI::log( sprintf( '  - #%d: %s (%s)', $id, $post->post_title, $post->post_type ) );
     514                }
     515                if ( $total > 10 ) {
     516                    WP_CLI::log( sprintf( '  ... and %d more', $total - 10 ) );
     517                }
     518            }
     519            return;
     520        }
     521
     522        if ( ! $porcelain ) {
     523            WP_CLI::log( sprintf( 'Found %d posts. Enriching...', $total ) );
     524        }
     525
     526        $progress        = $porcelain ? null : \WP_CLI\Utils\make_progress_bar( 'Enriching posts', $total );
     527        $total_images    = 0;
     528        $total_generated = 0;
     529        $post_handler    = new ATAI_Post();
     530
     531        foreach ( $post_ids as $index => $post_id ) {
     532            $result = $post_handler->enrich_post_content( $post_id, $force, $include_external );
     533
     534            if ( false === $result ) {
     535                if ( ! $porcelain ) {
     536                    WP_CLI::warning( sprintf( 'Post #%d not found, skipping.', $post_id ) );
     537                }
     538            } elseif ( is_array( $result ) ) {
     539                // Check for credit exhaustion.
     540                if ( ! empty( $result['no_credits'] ) ) {
     541                    if ( $progress ) {
     542                        $progress->finish();
     543                    }
     544                    WP_CLI::warning( sprintf( 'Ran out of credits after processing %d posts.', $index + 1 ) );
     545                    if ( $porcelain ) {
     546                        WP_CLI::line( (string) $total_generated );
     547                    }
     548                    return;
     549                }
     550
     551                $total_images    += $result['total_images_found'] ?? 0;
     552                $total_generated += $result['num_alttext_generated'] ?? 0;
     553            }
     554
     555            if ( $progress ) {
     556                $progress->tick();
     557            }
     558
     559            // Pause between posts to avoid rate limiting (skip after last).
     560            if ( $index < $total - 1 ) {
     561                usleep( 500000 ); // 0.5s
     562            }
     563        }
     564
     565        if ( $progress ) {
     566            $progress->finish();
     567        }
     568
     569        if ( $porcelain ) {
     570            WP_CLI::line( (string) $total_generated );
     571        } else {
     572            WP_CLI::success(
     573                sprintf(
     574                    'Complete: %d posts enriched, %d images found, %d alt texts generated.',
     575                    $total,
     576                    $total_images,
     577                    $total_generated
     578                )
     579            );
     580        }
     581    }
     582
     583    /**
     584     * Import alt text from a CSV file.
     585     *
     586     * CSV must contain 'asset_id' and 'alt_text' columns. Optionally include
     587     * a 'url' column for fallback matching by image URL.
     588     *
     589     * ## OPTIONS
     590     *
     591     * <file>
     592     * : Path to the CSV file.
     593     *
     594     * [--lang=<language>]
     595     * : Import from a language-specific column (e.g., alt_text_es). Falls back to alt_text if empty.
     596     *
     597     * [--dry-run]
     598     * : Show what would be imported without making changes.
     599     *
     600     * [--porcelain]
     601     * : Output only the count of imported images (for scripting).
     602     *
     603     * ## EXAMPLES
     604     *
     605     *     # Import alt text from CSV
     606     *     wp alttext import /path/to/export.csv
     607     *
     608     *     # Import Spanish alt text
     609     *     wp alttext import /path/to/export.csv --lang=es
     610     *
     611     *     # Preview what would be imported
     612     *     wp alttext import /path/to/export.csv --dry-run
     613     *
     614     * @when after_wp_load
     615     *
     616     * @param array $args       Positional arguments.
     617     * @param array $assoc_args Associative arguments.
     618     */
     619    public function import( $args, $assoc_args ) {
     620        $file_path = $args[0];
     621        $lang      = isset( $assoc_args['lang'] ) ? sanitize_text_field( $assoc_args['lang'] ) : '';
     622        $dry_run   = isset( $assoc_args['dry-run'] );
     623        $porcelain = isset( $assoc_args['porcelain'] );
     624
     625        // Validate file exists.
     626        if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) {
     627            WP_CLI::error( sprintf( 'File not found or not readable: %s', $file_path ) );
     628        }
     629
     630        // Open and validate CSV.
     631        $handle = fopen( $file_path, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
     632        if ( ! $handle ) {
     633            WP_CLI::error( sprintf( 'Could not open file: %s', $file_path ) );
     634        }
     635
     636        $header = fgetcsv( $handle, ATAI_CSV_LINE_LENGTH, ',', '"', '\\' );
     637        if ( ! $header ) {
     638            fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     639            WP_CLI::error( 'Could not read CSV header.' );
     640        }
     641
     642        // Find required columns.
     643        $asset_id_index  = array_search( 'asset_id', $header, true );
     644        $alt_text_index  = array_search( 'alt_text', $header, true );
     645        $image_url_index = array_search( 'url', $header, true );
     646
     647        if ( false === $asset_id_index || false === $alt_text_index ) {
     648            fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     649            WP_CLI::error( 'Invalid CSV: missing required columns (asset_id, alt_text).' );
     650        }
     651
     652        // Find language-specific column if requested.
     653        $lang_column_index = $alt_text_index;
     654        if ( ! empty( $lang ) ) {
     655            $lang_column_name  = 'alt_text_' . $lang;
     656            $found_lang_index  = array_search( $lang_column_name, $header, true );
     657            if ( false !== $found_lang_index ) {
     658                $lang_column_index = $found_lang_index;
     659            } elseif ( ! $porcelain ) {
     660                WP_CLI::warning( sprintf( 'Language column "%s" not found, using default alt_text column.', $lang_column_name ) );
     661            }
     662        }
     663
     664        // Count rows for progress bar without loading all into memory.
     665        $total = 0;
     666        while ( fgetcsv( $handle, ATAI_CSV_LINE_LENGTH, ',', '"', '\\' ) !== false ) {
     667            $total++;
     668        }
     669
     670        if ( 0 === $total ) {
     671            fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     672            if ( $porcelain ) {
     673                WP_CLI::line( '0' );
     674            } else {
     675                WP_CLI::success( 'CSV file contains no data rows.' );
     676            }
     677            return;
     678        }
     679
     680        // Rewind past header for streaming.
     681        rewind( $handle );
     682        fgetcsv( $handle, ATAI_CSV_LINE_LENGTH, ',', '"', '\\' ); // skip header
     683
     684        if ( $dry_run ) {
     685            // Count how many rows can be matched to attachments.
     686            $matchable = 0;
     687            while ( ( $data = fgetcsv( $handle, ATAI_CSV_LINE_LENGTH, ',', '"', '\\' ) ) !== false ) {
     688                $asset_id      = $data[ $asset_id_index ] ?? '';
     689                $attachment_id = ATAI_Utility::find_atai_asset( $asset_id );
     690
     691                if ( ! $attachment_id && false !== $image_url_index && isset( $data[ $image_url_index ] ) ) {
     692                    $attachment_id = attachment_url_to_postid( $data[ $image_url_index ] );
     693                }
     694
     695                if ( $attachment_id ) {
     696                    $matchable++;
     697                }
     698            }
     699            fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     700
     701            if ( $porcelain ) {
     702                WP_CLI::line( (string) $matchable );
     703            } else {
     704                WP_CLI::log( sprintf( 'Dry run: %d of %d rows match existing attachments.', $matchable, $total ) );
     705            }
     706            return;
     707        }
     708
     709        if ( ! $porcelain ) {
     710            WP_CLI::log( sprintf( 'Importing %d rows...', $total ) );
     711        }
     712
     713        $progress = $porcelain ? null : \WP_CLI\Utils\make_progress_bar( 'Importing alt text', $total );
     714        $imported = 0;
     715        $skipped  = 0;
     716
     717        while ( ( $data = fgetcsv( $handle, ATAI_CSV_LINE_LENGTH, ',', '"', '\\' ) ) !== false ) {
     718            $asset_id = $data[ $asset_id_index ] ?? '';
     719
     720            // Get alt text from language column with fallback.
     721            $alt_text = isset( $data[ $lang_column_index ] ) ? $data[ $lang_column_index ] : '';
     722            if ( empty( $alt_text ) && $lang_column_index !== $alt_text_index ) {
     723                $alt_text = isset( $data[ $alt_text_index ] ) ? $data[ $alt_text_index ] : '';
     724            }
     725
     726            // Sanitize alt text — strip HTML tags.
     727            $alt_text = wp_strip_all_tags( $alt_text );
     728
     729            // Skip rows with empty alt text to avoid overwriting existing values.
     730            if ( empty( $alt_text ) ) {
     731                $skipped++;
     732                if ( $progress ) {
     733                    $progress->tick();
     734                }
     735                continue;
     736            }
     737
     738            // Find attachment by asset ID.
     739            $attachment_id = ATAI_Utility::find_atai_asset( $asset_id );
     740
     741            // Fallback to URL lookup.
     742            if ( ! $attachment_id && false !== $image_url_index && isset( $data[ $image_url_index ] ) ) {
     743                $image_url     = esc_url_raw( $data[ $image_url_index ] );
     744                $attachment_id = $image_url ? attachment_url_to_postid( $image_url ) : 0;
     745
     746                if ( ! empty( $attachment_id ) && ! empty( $asset_id ) ) {
     747                    ATAI_Utility::record_atai_asset( $attachment_id, $asset_id );
     748                }
     749            }
     750
     751            if ( ! $attachment_id ) {
     752                $skipped++;
     753                if ( $progress ) {
     754                    $progress->tick();
     755                }
     756                continue;
     757            }
     758
     759            // Update alt text.
     760            update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt_text );
     761            $imported++;
     762
     763            // Update title/caption/description per plugin settings.
     764            $post_updates = array();
     765
     766            if ( ATAI_Utility::get_setting( 'atai_update_title' ) === 'yes' ) {
     767                $post_updates['post_title'] = sanitize_text_field( $alt_text );
     768            }
     769            if ( ATAI_Utility::get_setting( 'atai_update_caption' ) === 'yes' ) {
     770                $post_updates['post_excerpt'] = sanitize_textarea_field( $alt_text );
     771            }
     772            if ( ATAI_Utility::get_setting( 'atai_update_description' ) === 'yes' ) {
     773                $post_updates['post_content'] = sanitize_text_field( $alt_text );
     774            }
     775
     776            if ( ! empty( $post_updates ) ) {
     777                $post_updates['ID'] = $attachment_id;
     778                wp_update_post( $post_updates );
     779            }
     780
     781            if ( $progress ) {
     782                $progress->tick();
     783            }
     784        }
     785
     786        fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     787
     788        if ( $progress ) {
     789            $progress->finish();
     790        }
     791
     792        if ( $porcelain ) {
     793            WP_CLI::line( (string) $imported );
     794        } else {
     795            WP_CLI::success(
     796                sprintf(
     797                    'Complete: %d imported, %d skipped (no matching attachment or empty alt text).',
     798                    $imported,
     799                    $skipped
     800                )
     801            );
     802        }
     803    }
     804
     805    /**
     806     * Get published post IDs for enrichment.
     807     *
     808     * Only returns posts whose content contains an <img tag to avoid
     809     * iterating posts that have nothing to enrich.
     810     *
     811     * @param array $post_types Post types to query.
     812     * @param int   $limit      Maximum posts to return. -1 for all.
     813     *
     814     * @return array Array of post IDs.
     815     */
     816    private function get_posts_for_enrichment( $post_types, $limit ) {
     817        global $wpdb;
     818
     819        // Build placeholders for post types.
     820        $type_placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );
     821
     822        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $type_placeholders contains only %s placeholders generated by array_fill, not user data
     823    $sql = $wpdb->prepare(
     824            "SELECT ID FROM {$wpdb->posts}
     825            WHERE post_type IN ($type_placeholders)
     826            AND post_status = 'publish'
     827            AND post_content LIKE %s
     828            ORDER BY ID ASC",
     829            array_merge( $post_types, array( '%<img %' ) )
     830        );
     831    // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     832
     833        if ( $limit > 0 ) {
     834            $sql .= $wpdb->prepare( ' LIMIT %d', $limit );
     835        }
     836
     837        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
     838        return array_map( 'intval', $wpdb->get_col( $sql ) );
     839    }
     840
     841    /**
     842     * Verify an API key is configured, or halt with an error.
     843     */
     844    private function require_api_key() {
     845        if ( empty( ATAI_Utility::get_api_key() ) ) {
     846            WP_CLI::error( 'No API key configured. Set it in WordPress Admin → AltText.ai → Settings, or define ATAI_API_KEY constant.' );
     847        }
     848    }
     849
     850    /**
    382851     * Check if a generate_alt result indicates success.
    383852     *
     
    396865        $error_patterns = array( 'error_', 'invalid_', 'insufficient_credits', 'url_access_error' );
    397866        foreach ( $error_patterns as $pattern ) {
    398             if ( 0 === strpos( $result, $pattern ) || $result === $pattern ) {
     867            if ( 0 === strpos( $result, $pattern ) ) {
    399868                return false;
    400869            }
  • alttext-ai/trunk/includes/class-atai-post.php

    r3463711 r3466692  
    323323    $img_src_attr = ATAI_Utility::get_setting( 'atai_refresh_src_attr', 'src' );
    324324
     325    // Pause per-image Elementor sync during the loop to prevent read-modify-write
     326    // races on _elementor_data. A single comprehensive sync runs after the loop.
     327    ATAI_Elementor_Sync::$paused = true;
     328
    325329    if ( version_compare( get_bloginfo( 'version' ), '6.2') >= 0 ) {
    326330      $tags = new WP_HTML_Tag_Processor( $content );
     
    472476    }
    473477   
     478    ATAI_Elementor_Sync::$paused = false;
     479
    474480    if ( !empty($updated_content) ) {
    475481      wp_update_post( array(
     
    480486    }
    481487
     488    // Sync alt text into Elementor's cached page data after all images are processed.
     489    // Uses an action so the dependency on ATAI_Elementor_Sync stays in the bootstrapper.
     490    do_action( 'atai_post_enrichment_complete', $post_id );
     491
    482492    if ( $is_ajax ) {
    483493      // Set a transient to show a success notice after page reload
     
    498508      'status' => 'success',
    499509      'total_images_found' => $total_images_found,
    500       'num_alttext_generated' => $num_alttext_generated
     510      'num_alttext_generated' => $num_alttext_generated,
     511      'no_credits' => $no_credits,
    501512    );
    502513  }
     
    593604        $num_alttext_generated += $response['num_alttext_generated'] ?? 0;
    594605      }
     606
    595607    }
    596608
  • alttext-ai/trunk/includes/class-atai-utility.php

    r3463711 r3466692  
    402402    }
    403403
    404     // Check if network all settings is enabled - this is authoritative
     404    // API key is always fetched directly from the main site when any network sharing is
     405    // enabled. This bypasses the atai_network_settings cache which can hold a stale empty
     406    // value if the key was set after the cache was last written (e.g. network settings were
     407    // enabled before the API key was saved, causing the cache to record atai_api_key = '').
     408    if ( $option_name === 'atai_api_key' &&
     409         ( get_site_option( 'atai_network_all_settings' ) === 'yes' ||
     410           get_site_option( 'atai_network_api_key' ) === 'yes' ) ) {
     411      $main_site_id = get_main_site_id();
     412      switch_to_blog( $main_site_id );
     413      $value = get_option( $option_name, $default );
     414      restore_current_blog();
     415      return $value;
     416    }
     417
     418    // Check if network all settings is enabled - use the cache for non-API-key settings
    405419    if ( get_site_option( 'atai_network_all_settings' ) === 'yes' ) {
    406420      $network_settings = get_site_option( 'atai_network_settings', array() );
     
    408422        return $network_settings[ $option_name ];
    409423      }
    410       // Network controls all settings but key is missing - return default, not local option
    411       // This prevents subsites from accidentally using local values when network is authoritative
    412       return $default;
    413     }
    414 
    415     // Check if network API key is enabled (but not all settings)
    416     if ( get_site_option( 'atai_network_api_key' ) === 'yes' && $option_name === 'atai_api_key' ) {
    417       // Always fetch directly from main site to avoid stale cached values
     424      // Cache miss: read directly from main site rather than returning empty default.
    418425      $main_site_id = get_main_site_id();
    419426      switch_to_blog( $main_site_id );
  • alttext-ai/trunk/includes/class-atai.php

    r3463711 r3466692  
    137137        require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/class-atai-post.php';
    138138
     139    /**
     140         * The class responsible for syncing alt text into Elementor's cached data.
     141         */
     142        require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/class-atai-elementor-sync.php';
     143
    139144        /**
    140145         * The class responsible for defining all actions that occur in the admin area.
     
    180185     */
    181186    private function define_admin_hooks() {
    182         $database = new ATAI_Database();
    183         $admin = new ATAI_Admin( $this->get_plugin_name(), $this->get_version() );
    184         $settings = new ATAI_Settings( $this->get_version() );
    185         $attachment = new ATAI_Attachment();
    186         $post = new ATAI_Post();
     187        $database        = new ATAI_Database();
     188        $admin           = new ATAI_Admin( $this->get_plugin_name(), $this->get_version() );
     189        $settings        = new ATAI_Settings( $this->get_version() );
     190        $attachment      = new ATAI_Attachment();
     191        $post            = new ATAI_Post();
     192        $elementor_sync  = new ATAI_Elementor_Sync();
    187193
    188194    // Database
     
    210216    $this->loader->add_filter( 'option_page_capability_atai-settings', $settings, 'filter_settings_capability' );
    211217
     218    // Network Bulk Generate
     219    $this->loader->add_action( 'network_admin_menu', $settings, 'register_network_bulk_generate_page' );
     220    if ( is_multisite() ) {
     221      $this->loader->add_action( 'wp_ajax_atai_network_get_stats', $attachment, 'ajax_network_get_stats' );
     222      $this->loader->add_action( 'wp_ajax_atai_network_bulk_generate', $attachment, 'ajax_network_bulk_generate' );
     223    }
     224
    212225    // Refresh network settings cache when any setting is updated (multisite only)
    213226    if ( is_multisite() ) {
     
    241254    $this->loader->add_filter( 'the_content', $post, 'sync_alt_text_to_content', 999 );
    242255
     256    // Sync media library alt text into Elementor's cached image data.
     257    // Hook both added_post_meta (first-time alt text) and updated_post_meta (subsequent edits).
     258    $this->loader->add_action( 'added_post_meta', $elementor_sync, 'sync_alt_to_elementor', 10, 4 );
     259    $this->loader->add_action( 'updated_post_meta', $elementor_sync, 'sync_alt_to_elementor', 10, 4 );
     260
     261    // After bulk post enrichment, do one comprehensive Elementor sync for the page
     262    // (avoids per-image read-modify-write races during the bulk loop).
     263    $this->loader->add_action( 'atai_post_enrichment_complete', $elementor_sync, 'sync_post' );
     264
    243265    // Other plugin integrations
    244266    $this->loader->add_action( 'pll_translate_media', $attachment, 'on_translation_created', 99, 3 );
Note: See TracChangeset for help on using the changeset viewer.