Changeset 3466692
- Timestamp:
- 02/22/2026 03:54:25 AM (2 weeks ago)
- Location:
- alttext-ai/trunk
- Files:
-
- 12 edited
-
README.txt (modified) (2 diffs)
-
admin/class-atai-settings.php (modified) (2 diffs)
-
admin/css/admin.css (modified) (1 diff)
-
admin/partials/settings.php (modified) (1 diff)
-
atai.php (modified) (2 diffs)
-
changelog.txt (modified) (1 diff)
-
includes/class-atai-api.php (modified) (2 diffs)
-
includes/class-atai-attachment.php (modified) (1 diff)
-
includes/class-atai-cli.php (modified) (6 diffs)
-
includes/class-atai-post.php (modified) (5 diffs)
-
includes/class-atai-utility.php (modified) (2 diffs)
-
includes/class-atai.php (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
alttext-ai/trunk/README.txt
r3463711 r3466692 6 6 Requires at least: 4.7 7 7 Tested up to: 6.9 8 Stable tag: 1.10.2 28 Stable tag: 1.10.25 9 9 WC requires at least: 3.3 10 10 WC tested up to: 10.1 … … 71 71 72 72 == 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 73 79 74 80 = 1.10.22 - 2026-02-17 = -
alttext-ai/trunk/admin/class-atai-settings.php
r3450493 r3466692 181 181 wp_enqueue_style( 'atai-admin', plugin_dir_url( __FILE__ ) . 'css/admin.css', array(), $this->version, 'all' ); 182 182 } 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 ) ); 183 234 } 184 235 … … 862 913 */ 863 914 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 864 920 // Bail early if notice transient is not set 865 921 if ( ! get_transient( 'atai_insufficient_credits' ) ) { -
alttext-ai/trunk/admin/css/admin.css
r3466688 r3466692 1701 1701 } 1702 1702 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 828 828 </div> 829 829 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> 833 836 </form> 834 837 </div> -
alttext-ai/trunk/atai.php
r3463711 r3466692 16 16 * Plugin URI: https://alttext.ai/product 17 17 * Description: Automatically generate image alt text with AltText.ai. 18 * Version: 1.10.2 218 * Version: 1.10.25 19 19 * Author: AltText.ai 20 20 * Author URI: https://alttext.ai … … 31 31 } 32 32 33 33 34 /** 34 35 * Current plugin version. 35 36 */ 36 define( 'ATAI_VERSION', '1.10.2 2' );37 define( 'ATAI_VERSION', '1.10.25' ); 37 38 38 39 /** -
alttext-ai/trunk/changelog.txt
r3463711 r3466692 1 1 *** AltText.ai Changelog *** 2 3 2026-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 2 8 3 9 2026-02-17 - version 1.10.22 -
alttext-ai/trunk/includes/class-atai-api.php
r3453350 r3466692 144 144 $file_contents = @file_get_contents( $file_path ); 145 145 if ( $file_contents === false ) { 146 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log, QITStandard.PHP.DebugCode.DebugFunctionFound -- Production error logging 146 147 error_log( "ATAI: Failed to read file for attachment {$attachment_id}" ); 147 148 return false; … … 150 151 $encoded_content = @base64_encode( $file_contents ); 151 152 if ( $encoded_content === false ) { 153 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log, QITStandard.PHP.DebugCode.DebugFunctionFound -- Production error logging 152 154 error_log( "ATAI: Failed to encode file for attachment {$attachment_id}" ); 153 155 return false; -
alttext-ai/trunk/includes/class-atai-attachment.php
r3455483 r3466692 2344 2344 $query->set( 'meta_query', $meta_query ); 2345 2345 } 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 } 2346 2547 } -
alttext-ai/trunk/includes/class-atai-cli.php
r3455483 r3466692 4 4 * 5 5 * @link https://alttext.ai 6 * @since 1.1 1.06 * @since 1.10.23 7 7 * 8 8 * @package ATAI … … 32 32 * wp alttext status 33 33 * 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 35 44 */ 36 45 class ATAI_CLI_Command { … … 86 95 $batch_size = max( 1, $batch_size ); 87 96 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(); 93 108 94 109 // Get eligible images. … … 165 180 $failed++; 166 181 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 ) ); 168 183 } 169 184 } … … 380 395 381 396 /** 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 /** 382 851 * Check if a generate_alt result indicates success. 383 852 * … … 396 865 $error_patterns = array( 'error_', 'invalid_', 'insufficient_credits', 'url_access_error' ); 397 866 foreach ( $error_patterns as $pattern ) { 398 if ( 0 === strpos( $result, $pattern ) || $result === $pattern) {867 if ( 0 === strpos( $result, $pattern ) ) { 399 868 return false; 400 869 } -
alttext-ai/trunk/includes/class-atai-post.php
r3463711 r3466692 323 323 $img_src_attr = ATAI_Utility::get_setting( 'atai_refresh_src_attr', 'src' ); 324 324 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 325 329 if ( version_compare( get_bloginfo( 'version' ), '6.2') >= 0 ) { 326 330 $tags = new WP_HTML_Tag_Processor( $content ); … … 472 476 } 473 477 478 ATAI_Elementor_Sync::$paused = false; 479 474 480 if ( !empty($updated_content) ) { 475 481 wp_update_post( array( … … 480 486 } 481 487 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 482 492 if ( $is_ajax ) { 483 493 // Set a transient to show a success notice after page reload … … 498 508 'status' => 'success', 499 509 '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, 501 512 ); 502 513 } … … 593 604 $num_alttext_generated += $response['num_alttext_generated'] ?? 0; 594 605 } 606 595 607 } 596 608 -
alttext-ai/trunk/includes/class-atai-utility.php
r3463711 r3466692 402 402 } 403 403 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 405 419 if ( get_site_option( 'atai_network_all_settings' ) === 'yes' ) { 406 420 $network_settings = get_site_option( 'atai_network_settings', array() ); … … 408 422 return $network_settings[ $option_name ]; 409 423 } 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. 418 425 $main_site_id = get_main_site_id(); 419 426 switch_to_blog( $main_site_id ); -
alttext-ai/trunk/includes/class-atai.php
r3463711 r3466692 137 137 require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/class-atai-post.php'; 138 138 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 139 144 /** 140 145 * The class responsible for defining all actions that occur in the admin area. … … 180 185 */ 181 186 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(); 187 193 188 194 // Database … … 210 216 $this->loader->add_filter( 'option_page_capability_atai-settings', $settings, 'filter_settings_capability' ); 211 217 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 212 225 // Refresh network settings cache when any setting is updated (multisite only) 213 226 if ( is_multisite() ) { … … 241 254 $this->loader->add_filter( 'the_content', $post, 'sync_alt_text_to_content', 999 ); 242 255 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 243 265 // Other plugin integrations 244 266 $this->loader->add_action( 'pll_translate_media', $attachment, 'on_translation_created', 99, 3 );
Note: See TracChangeset
for help on using the changeset viewer.