Plugin Directory

Changeset 3493820


Ignore:
Timestamp:
03/29/2026 12:38:18 PM (3 days ago)
Author:
klimentp
Message:

Version 1.1.3 - Parallel Image Scheduler, Connection Timeout Fix, Disconnection Hooks Fix

Location:
draftseo-ai
Files:
230 added
6 edited

Legend:

Unmodified
Added
Removed
  • draftseo-ai/trunk/README.md

    r3491200 r3493820  
    4747The plugin intelligently handles images based on blog size:
    4848
    49 - **1-5 images**: Direct import (10-20 seconds)
     49- **1-5 images**: Direct import — all images downloaded in parallel and imported immediately (typically 5–15 seconds)
    5050- **6+ images**: Hybrid approach
    5151  - Featured image imported immediately
    52   - Remaining images processed in background via WordPress Cron
     52  - Remaining images downloaded in parallel and imported in the background via Action Scheduler (no page visit needed)
    5353
    5454All images are downloaded directly from DraftSEO.ai to your WordPress Media Library.
     
    173173## Changelog
    174174
     175### 1.1.3
     176
     177Reliability improvements, faster image loading, and cleaner background processing.
     178
     179- **Images now load reliably on quiet sites** — Images were sometimes not appearing on published posts on low-traffic websites because background download jobs weren't starting until the next page visit. They now start immediately regardless of site traffic.
     180- **Clean deactivation** — Switching the plugin off while a publish was in progress could leave background tasks running after deactivation. The plugin now cleanly stops all background jobs when it is turned off.
     181- **Proper uninstall disconnect** — Removing the plugin now correctly notifies DraftSEO.AI and closes the connection before all plugin data is deleted.
     182- **Translations fixed** — Plugin text was not translating correctly on WordPress sites running in a language other than English. Translation files now load properly.
     183- **Action Scheduler for background jobs** — Background image jobs now run via Action Scheduler instead of WP Cron. Action Scheduler does not need a page visit to start — it runs as a true background process — and retries jobs automatically if a download fails. Pending and completed jobs are visible in the WordPress admin at **Tools > Scheduled Actions**.
     184- **Parallel image downloads** — Images in background jobs are now downloaded simultaneously before being imported into the Media Library. For a blog with 20 images this cuts total download time from roughly 60–100 seconds (sequential) to 5–15 seconds (parallel).
     185- **No duplicate images on republish** — Republishing a post was creating a duplicate Media Library entry for every image that had not changed since the previous publish. Only new or replaced images are now downloaded and imported — unchanged images are reused from the existing entry, keeping the Media Library clean.
     186
    175187### 1.1.2
    176188
     
    179191### 1.1.0
    180192
    181 - **Image Replacement** — When a new image is generated and republished from DraftSEO.ai, replace the image inside the blog automatically.
    182 - **Assets Cleanup** — The old now-unused image is auto-deleted from the Media folder in Wordpress. Saves storage space, cleans up unused media assets.
     193Automatic cleanup when republishing with a new image.
     194
     195- **Image replacement** — When you republish a post with a new AI-generated image, the new image is swapped in automatically
     196- **Media cleanup** — The previous image is removed from your WordPress Media Library — keeps your media folder clean and saves storage space
    183197
    184198### 1.0.5
     
    208222- **In-text citations** — `[1]`, `[2]` markers now render as clickable superscript links that scroll to the matching reference in the References section
    209223- **References section** — Converted to a styled numbered list with anchor IDs (`#ref-1`, `#ref-2`) for citation linking; supports all reference styles (url_title, harvard_apa6, mla9, etc.)
    210 - **External links** — All external links now open in a new tab with `target="_blank"` and `rel="noopener noreferrer"` for security and better UX
    211 - **FAQ Schema (JSON-LD)** — FAQ question-answer pairs are extracted from blog content and injected as structured data in the WordPress post `<head>` for Google FAQ rich results
    212 - **Front-end CSS** — Plugin now injects lightweight CSS for consistent styling of citations, references, and tables across all WordPress themes
    213 - **Content sanitization** — Updated `wp_kses` allowlist to support `<sup>`, `<ol>`/`<li>` with `id`/`value`/`class`, and `<a>` with `target`/`rel` attributes
     224- **External links** — All external links now open in a new tab with `target="_blank"` and `rel="noopener noreferrer"`
     225- **FAQ structured data (JSON-LD)** — FAQ question-answer pairs are extracted from blog content and injected into the post `<head>` for Google FAQ rich results
     226- **Theme-consistent styling** — CSS is injected for citations, references, and tables so they display correctly across all WordPress themes
     227- **HTML element support** — `<sup>` for citations, `<ol>`/`<li>` with `id` attributes, and `<a>` with `target`/`rel` are now preserved in published post content
    214228- **Active sites filter** — WordPress site dropdown now only shows active/connected sites
    215229
     
    218232Hotfix for content rendering in published posts.
    219233
    220 - **YouTube video embeds** — Now render correctly using WordPress oEmbed (previously stripped by sanitization)
    221 - **Data tables** — Now display as formatted HTML tables instead of raw markdown text
    222 - **Content processor** — Updated to use custom sanitization allowlist with full table tag support
    223 - **Removed legacy markdown-to-HTML converter** — All content conversion now handled on the platform side before sending
     234- **YouTube embeds** — Were being stripped during publishing; now render as embedded players on your site
     235- **Data tables** — Were displaying as raw Markdown text; now output as formatted HTML tables
    224236
    225237### 1.0.0
    226238
    227 Major release with 30+ improvements across security, stability, performance, and API architecture.
    228 
    229 #### Security (6 improvements)
    230 - **HMAC-SHA256 webhook authentication** — Deactivation and disconnect webhooks now sign payloads with HMAC-SHA256 using the API key as the secret; the API key is never transmitted over the wire. Headers: `X-DraftSEO-Signature`, `X-DraftSEO-Timestamp`
    231 - **Replay protection** — Webhook requests include a Unix timestamp; requests older than 5 minutes are rejected to prevent replay attacks
    232 - **Timing-safe comparisons** — All API key and signature comparisons use `hash_equals()` (PHP) and `crypto.timingSafeEqual` (Node.js) to prevent timing-based side-channel attacks
    233 - **AES-256-CBC encryption** — API keys stored at rest using AES-256-CBC with a random IV per encryption, derived from WordPress auth salt (site-specific, not hardcoded)
    234 - **Improved deactivation hook** — Now reads the API key via `DraftSEO_Settings::get_api_key()` (properly decrypted) for more reliable key handling
    235 - **Enhanced key validation** — `verify_api_key()` now explicitly validates both stored and provided keys with specific, actionable error codes
    236 
    237 #### API & REST Endpoint Improvements (7 improvements)
    238 - **New `/tags` endpoint** — Added `GET /wp-json/draftseo/v1/tags` for tag synchronization, matching the existing `/users` and `/categories` endpoints
    239 - **Unified endpoint architecture** — All three sync resources (users, categories, tags) now use the same plugin-first-then-fallback pattern via `fetchWithPluginFallback()`
    240 - **Structured error responses** — All error responses now use proper `WP_Error` objects with specific error codes (`rest_forbidden`, `rest_missing_param`, `rest_publish_error`, `rest_update_error`, `rest_post_not_found`, `rest_tags_error`) for better debugging and integration
    241 - **`rest_ensure_response()`** — All success responses now use `rest_ensure_response()` per WordPress REST API Handbook, allowing WordPress filters to process responses through the standard pipeline
    242 - **Input validation arguments** — `/publish` and `/update` routes now define `args` with `validate_callback` and `sanitize_callback` for server-side input validation before the handler runs
    243 - **Remote disconnect endpoint** — `/remote-disconnect` properly clears stored API key and connection settings when triggered from DraftSEO.ai platform
    244 - **Bidirectional disconnect sync** — When a user disconnects from DraftSEO.ai, the platform now calls the plugin's `/remote-disconnect` endpoint before local deletion, keeping both sides in sync
    245 
    246 #### Stability & Error Handling (6 improvements)
    247 - **Non-JSON response resilience** — Gracefully handles HTML maintenance pages, WAF blocks, and 503 errors from WordPress instead of failing silently
    248 - **Sync endpoint timeout & abort** — Added configurable timeout with AbortController to prevent hanging sync requests
    249 - **Error isolation** — Per-card Error Boundaries in the WordPress site list ensure individual site issues don't affect other connected sites
    250 - **Guarded data access** — All connection data property accesses use optional chaining with fallbacks for maximum reliability
    251 - **Response validation** — API responses are validated as proper arrays/objects before processing for robust data handling
    252 - **Health check hardening** — Health check response parsing improved with dedicated error paths for edge cases
    253 
    254 #### Performance & Optimization (4 improvements)
    255 - **Parallel sync** — Users, categories, and tags are fetched simultaneously via `Promise.all()` instead of sequentially
    256 - **Smart retry logic** — 4xx client errors (401, 403, 400, 422) skip retry entirely; only 5xx server errors are retried, reducing wasted API calls
    257 - **Optimized cache invalidation** — Streamlined cache invalidation strategy; added health check invalidation after sync for immediate UI updates
    258 - **Image import strategy** — Intelligent strategy selection: 1-5 images use direct import (fast), 6+ images use hybrid approach (featured image immediate, rest via WordPress Cron background processing)
     239Major release — security hardening, reliability improvements, and full tag management.
     240
     241#### Security
     242- **Webhook signatures** — Disconnect and deactivation notifications are signed with HMAC-SHA256 (`X-DraftSEO-Signature`, `X-DraftSEO-Timestamp` headers); the API key is the signing secret and is never transmitted in plain text
     243- **Replay protection** — Signed requests include a Unix timestamp; requests older than 5 minutes are rejected
     244- **API keys encrypted at rest** — AES-256-CBC with a unique IV per key, derived from the WordPress site's auth salt
     245
     246#### Publishing & REST API
     247- **Tags endpoint** — `GET /wp-json/draftseo/v1/tags` added for tag sync, matching the existing `/users` and `/categories` endpoints
     248- **Server-side input validation** — `/publish` and `/update` routes validate and sanitise all params before the handler runs
     249- **Structured error responses** — All errors return specific codes (`rest_forbidden`, `rest_missing_param`, `rest_publish_error`, etc.) for better debugging
     250- **Bidirectional disconnect** — Disconnecting from DraftSEO.AI calls `/remote-disconnect` to clear connection settings on the plugin side automatically
     251
     252#### Reliability
     253- **Non-JSON response handling** — Gracefully handles HTML maintenance pages, WAF blocks, and 503 responses instead of failing silently
     254- **Sync timeouts** — All sync requests have a configurable timeout; no more indefinitely hung connections
     255- **Error isolation** — Individual site connection errors in the multi-site view do not affect other connected sites
     256
     257#### Performance
     258- **Parallel sync** — Users, categories, and tags are fetched simultaneously instead of sequentially
     259- **Smart retries** — 4xx errors (401, 403, 400, 422) fail immediately without retrying; only 5xx server errors trigger retries
     260
     261#### Tag Management
     262- Auto-create WordPress tags from AI-generated keywords at publish time (configurable, 1–10 tags)
     263- Select from existing WordPress tags, or create new ones on the fly during publishing
     264
     265#### Image Handling
     266- All images downloaded directly to your WordPress Media Library
     267- Alt text from DraftSEO.AI preserved as WordPress image alt text
     268- Featured image set automatically; post content image URLs updated from DraftSEO.AI CDN to local Media Library URLs
    259269
    260270#### Usability
    261 - **Settings link on Plugins page** — Added "Settings" quick-access link on the Plugins page (next to Deactivate) for one-click access to plugin configuration
    262 
    263 #### WordPress Best Practices
    264 - Requires WordPress 6.2+ and PHP 7.4+
    265 - Follows WordPress Coding Standards (WPCS)
    266 - Uses `wp_kses_post()` for content sanitization
    267 - Nonces for admin AJAX security
    268 - Capability checks (`manage_options`) for settings access
    269 - Content cleanup: responsive table wrapping, blockquote formatting
    270 - Publication logging to custom database table
    271 - Image duplicate detection via URL hash with WordPress object cache
    272 
    273 #### Tag Management
    274 - Auto-create tags from AI-generated keywords (configurable 1-10 count)
    275 - Manual tag selection from existing WordPress tags
    276 - Custom tags: create new tags on-the-fly during publishing
    277 
    278 #### Image Handling
    279 - Direct download from DraftSEO.ai to WordPress Media Library
    280 - Alt text and heading text metadata preserved
    281 - Featured image setting with URL replacement in post content (Nebius URLs → local WordPress URLs)
    282 - Background processing via WordPress Cron for large image sets (6+ images)
     271- "Settings" quick-link added to the Plugins page for faster access to plugin configuration
    283272
    284273### 0.2.0 (Initial Beta)
  • draftseo-ai/trunk/draftseo-ai.php

    r3491200 r3493820  
    33 * Plugin Name: DraftSEO.AI
    44 * Plugin URI: https://draftseo.ai/wp-plugin
    5  * Description: Publish AI-generated blogs from DraftSEO.AI platform directly to WordPress. Transfers images from Nebius CDN to WordPress media library while maintaining SEO optimization.
    6  * Version: 1.1.2
     5 * Description: Publish AI-generated blogs from DraftSEO.AI platform directly to WordPress. Transfers images to WordPress media library while maintaining SEO optimization.
     6 * Version: 1.1.3
    77 * Author: DraftSEO.AI
    88 * Author URI: https://draftseo.ai
     
    3838
    3939// Define plugin constants
    40 define('DRAFTSEO_VERSION', '1.1.2');
     40define('DRAFTSEO_VERSION', '1.1.3');
    4141define('DRAFTSEO_PLUGIN_DIR', plugin_dir_path(__FILE__));
    4242define('DRAFTSEO_PLUGIN_URL', plugin_dir_url(__FILE__));
    4343define('DRAFTSEO_PLUGIN_BASENAME', plugin_basename(__FILE__));
     44
     45// Load Action Scheduler (bundled in vendor/action-scheduler/).
     46// AS self-initialises via plugins_loaded — no manual init call needed.
     47// When multiple plugins bundle AS, WordPress loads the newest version automatically.
     48if (file_exists(DRAFTSEO_PLUGIN_DIR . 'vendor/action-scheduler/action-scheduler.php')) {
     49    require_once DRAFTSEO_PLUGIN_DIR . 'vendor/action-scheduler/action-scheduler.php';
     50}
    4451
    4552/**
     
    9198     */
    9299    private function init_hooks() {
     100        // Load plugin text domain for translations
     101        add_action('plugins_loaded', array($this, 'load_textdomain'));
     102
    93103        // Activation and deactivation hooks
    94104        register_activation_hook(__FILE__, array($this, 'activate'));
     
    117127    }
    118128   
     129    /**
     130     * Load plugin text domain for i18n/translations
     131     */
     132    public function load_textdomain() {
     133        load_plugin_textdomain(
     134            'draftseo-ai',
     135            false,
     136            dirname(plugin_basename(__FILE__)) . '/languages/'
     137        );
     138    }
     139
    119140    /**
    120141     * Add action links to the plugin listing on the Plugins page
     
    158179        // Notify DraftSEO.AI platform about plugin deactivation
    159180        $this->notify_draftseo_deactivation('deactivated');
    160        
    161         // Clear scheduled cron events (if any)
    162         $timestamp = wp_next_scheduled('draftseo_process_images_background');
    163         if ($timestamp) {
    164             wp_unschedule_event($timestamp, 'draftseo_process_images_background');
    165         }
    166        
     181
     182        // Cancel all pending Action Scheduler jobs for this plugin.
     183        // as_unschedule_all_actions() removes every queued occurrence of a hook,
     184        // not just the next one — correct approach for full cleanup.
     185        if (function_exists('as_unschedule_all_actions')) {
     186            as_unschedule_all_actions('draftseo_process_images_background', array(), 'draftseo-ai');
     187            as_unschedule_all_actions('draftseo_process_images_with_callback', array(), 'draftseo-ai');
     188        }
     189
     190        // Also clear any legacy WP Cron events from plugin versions < 1.1.3
     191        // that may still be sitting in the wp_options cron queue.
     192        wp_clear_scheduled_hook('draftseo_process_images_background');
     193        wp_clear_scheduled_hook('draftseo_process_images_with_callback');
     194
    167195        // Flush rewrite rules
    168196        flush_rewrite_rules();
  • draftseo-ai/trunk/includes/class-image-handler.php

    r3478117 r3493820  
    33 * Image Handler Class
    44 *
    5  * Handles image import from Nebius CDN to WordPress Media Library
    6  * Implements 3 strategies: Direct, Async, and Hybrid
     5 * Handles image import from cloud storage CDN to WordPress Media Library.
     6 * Implements 3 strategies: Direct, Async, and Hybrid.
     7 *
     8 * Background jobs are scheduled via Action Scheduler (bundled in vendor/)
     9 * instead of WP Cron. Action Scheduler provides true background processing
     10 * (no page-visit dependency), built-in retry on failure, and an admin UI
     11 * at Tools > Scheduled Actions for visibility into queued jobs.
     12 *
     13 * Within each background job, images are downloaded in parallel using
     14 * curl_multi before being imported sequentially into the WordPress Media
     15 * Library. Parallel downloads reduce total download time from 60–100 s
     16 * (sequential) to ~5–15 s (parallel) for a typical 20-image blog.
    717 *
    818 * @package DraftSEO_Publisher
     
    1626
    1727class DraftSEO_Image_Handler {
    18    
    19     /**
    20      * Import images from Nebius CDN
     28
     29    /**
     30     * Import images from DraftSEO CDN
    2131     *
    2232     * Automatically selects best strategy based on image count:
    23      * - 1-5 images: Direct (media_sideload_image)
    24      * - 6+ images: Hybrid (featured immediate + rest async)
     33     * - 1-5 images: Direct (curl_multi parallel download + sequential import)
     34     * - 6+ images: Hybrid (featured immediate + rest via Action Scheduler)
    2535     *
    2636     * @param array $images Array of image objects from DraftSEO.AI
     
    3646            );
    3747        }
    38        
     48
    3949        $image_count = count($images);
    40        
     50
    4151        // Strategy selection based on image count
    4252        if ($image_count <= 5) {
    43             // Strategy 1: Direct import
     53            // Strategy 1: Direct import (curl_multi parallel download)
    4454            return self::import_images_direct($images, $post_id, $set_featured);
    4555        } else {
    46             // Strategy 3: Hybrid (featured immediate + rest async)
     56            // Strategy 3: Hybrid (featured immediate + rest via Action Scheduler)
    4757            return self::import_images_hybrid($images, $post_id, $set_featured);
    4858        }
    4959    }
    50    
    51     /**
    52      * Strategy 1: Direct import using media_sideload_image()
    53      *
    54      * Best for 1-5 images - fast and reliable
     60
     61    /**
     62     * Queue ALL images to Action Scheduler immediately and return, then fire
     63     * the DraftSEO callback URL when the background job completes.
     64     *
     65     * Used when DraftSEO sends a signed callbackUrl in the publish request.
     66     * The server gets an HTTP 200 response right away instead of waiting for
     67     * every image download — eliminating the connection-timeout error that
     68     * occurs when large image sets keep the request open for minutes.
     69     *
     70     * @param array  $images       Array of image objects from DraftSEO.AI
     71     * @param int    $post_id      WordPress post ID
     72     * @param bool   $set_featured Whether to set first image as featured
     73     * @param string $callback_url Signed DraftSEO webhook URL for completion report
     74     * @return array Minimal result with queued_count (imported_count is always 0)
     75     */
     76    public static function import_images_async($images, $post_id, $set_featured, $callback_url) {
     77        if (empty($images) || !is_array($images)) {
     78            // Nothing to import — fire callback immediately
     79            self::fire_callback($callback_url, true, 0, 0);
     80            return array('imported_count' => 0, 'queued_count' => 0, 'url_mapping' => array(), 'strategy' => 'async');
     81        }
     82
     83        // Schedule ALL images (including featured) as a single Action Scheduler job.
     84        // Args are wrapped in an outer array so the handler receives them as one
     85        // $args array, matching the existing function signature.
     86        if (function_exists('as_schedule_single_action')) {
     87            as_schedule_single_action(
     88                time(),
     89                'draftseo_process_images_with_callback',
     90                array(
     91                    array(
     92                        'post_id'      => $post_id,
     93                        'images'       => $images,
     94                        'set_featured' => $set_featured,
     95                        'callback_url' => $callback_url,
     96                    )
     97                ),
     98                'draftseo-ai'
     99            );
     100        } else {
     101            // Fallback to WP Cron if Action Scheduler is unavailable
     102            wp_schedule_single_event(
     103                time(),
     104                'draftseo_process_images_with_callback',
     105                array(
     106                    'post_id'      => $post_id,
     107                    'images'       => $images,
     108                    'set_featured' => $set_featured,
     109                    'callback_url' => $callback_url,
     110                )
     111            );
     112            spawn_cron();
     113        }
     114
     115        return array(
     116            'imported_count' => 0,
     117            'queued_count'   => count($images),
     118            'url_mapping'    => array(),
     119            'strategy'       => 'async'
     120        );
     121    }
     122
     123    /**
     124     * Strategy 1: Direct import using curl_multi parallel download
     125     *
     126     * Downloads all images simultaneously, then imports each into the WordPress
     127     * Media Library sequentially. Best for 1-5 images — fast and reliable.
     128     *
     129     * Sequential import is required: WordPress generates multiple image sizes
     130     * (thumbnails via GD/ImageMagick) per attachment — a CPU-bound step that
     131     * cannot be parallelized safely on shared hosting.
    55132     *
    56133     * @param array $images Array of image objects
     
    63140        require_once(ABSPATH . 'wp-admin/includes/file.php');
    64141        require_once(ABSPATH . 'wp-admin/includes/image.php');
    65        
     142
    66143        $imported_count = 0;
    67         $url_mapping = array();
    68        
     144        $url_mapping    = array();
     145
     146        // Phase 1: Download all images in parallel using curl_multi
     147        $tmp_map = self::parallel_download_images($images);
     148
     149        // Phase 2: Import each downloaded file into the Media Library sequentially
    69150        foreach ($images as $index => $image) {
    70151            if (empty($image['url'])) {
    71152                continue;
    72153            }
    73            
    74             // Download from Nebius CDN
    75             $attachment_id = media_sideload_image(
    76                 $image['url'],
    77                 $post_id,
    78                 isset($image['altText']) ? $image['altText'] : '',
    79                 'id'
    80             );
    81            
     154
     155            $url = esc_url_raw($image['url']);
     156
     157            if (!isset($tmp_map[$url])) {
     158                continue; // Download failed for this URL — skip
     159            }
     160
     161            // Skip re-import if already in the Media Library — avoids duplicate entries on republish
     162            $existing_id = self::find_existing_image($url);
     163            if ($existing_id) {
     164                @unlink($tmp_map[$url]); // Temp file not needed — clean up immediately
     165                if ($set_featured && 0 === $index) {
     166                    set_post_thumbnail($post_id, $existing_id);
     167                }
     168                $wp_url = wp_get_attachment_url($existing_id);
     169                if ($wp_url) {
     170                    $url_mapping[$url] = $wp_url;
     171                    $imported_count++;
     172                }
     173                continue;
     174            }
     175
     176            $attachment_id = self::import_from_temp($tmp_map[$url], $url, $post_id, $image);
     177
    82178            if (is_wp_error($attachment_id)) {
    83179                continue;
    84180            }
    85            
    86             // Update alt text
    87             if (isset($image['altText'])) {
    88                 update_post_meta($attachment_id, '_wp_attachment_image_alt', sanitize_text_field($image['altText']));
    89             }
    90            
    91             // Store heading text if provided
    92             if (isset($image['headingText'])) {
    93                 update_post_meta($attachment_id, 'draftseo_heading_text', sanitize_text_field($image['headingText']));
    94             }
    95            
     181
    96182            // Set as featured image (first image only)
    97             if ($set_featured && $index === 0) {
     183            if ($set_featured && 0 === $index) {
    98184                set_post_thumbnail($post_id, $attachment_id);
    99185            }
    100            
    101             // Store URL hash for future lookup (enables deletion on republish)
    102             self::store_image_hash($attachment_id, $image['url']);
    103            
    104             // Store URL mapping for replacement
     186
    105187            $wp_url = wp_get_attachment_url($attachment_id);
    106             $url_mapping[$image['url']] = $wp_url;
    107            
     188            $url_mapping[$url] = $wp_url;
    108189            $imported_count++;
    109190        }
    110        
     191
    111192        return array(
    112193            'imported_count' => $imported_count,
    113             'url_mapping' => $url_mapping,
    114             'strategy' => 'direct'
     194            'url_mapping'    => $url_mapping,
     195            'strategy'       => 'direct'
    115196        );
    116197    }
    117    
    118     /**
    119      * Strategy 3: Hybrid approach (PRODUCTION DEFAULT)
    120      *
    121      * Downloads featured image immediately, queues rest for background processing
    122      * Best for 6+ images
     198
     199    /**
     200     * Strategy 3: Hybrid approach (PRODUCTION DEFAULT for >= 6 images)
     201     *
     202     * Downloads featured image immediately so the post thumbnail is visible
     203     * right away, then queues remaining images to Action Scheduler for true
     204     * background processing — no page-visit required to start the job.
     205     *
     206     * Best for 6+ images without a callbackUrl.
    123207     *
    124208     * @param array $images Array of image objects
     
    131215        require_once(ABSPATH . 'wp-admin/includes/file.php');
    132216        require_once(ABSPATH . 'wp-admin/includes/image.php');
    133        
     217
    134218        $url_mapping = array();
    135219        $featured_id = null;
    136        
     220
    137221        // Download ONLY featured image immediately (first image)
    138222        if ($set_featured && !empty($images[0])) {
    139223            $featured_image = $images[0];
    140            
    141             $featured_id = media_sideload_image(
    142                 $featured_image['url'],
    143                 $post_id,
    144                 isset($featured_image['altText']) ? $featured_image['altText'] : '',
    145                 'id'
    146             );
    147            
     224            $featured_url   = esc_url_raw($featured_image['url']);
     225
     226            // Skip download if already in the Media Library — avoids duplicate entries on republish
     227            $existing_featured_id = self::find_existing_image($featured_url);
     228            if ($existing_featured_id) {
     229                $featured_id = $existing_featured_id;
     230            } else {
     231                // Single-image download via WordPress's built-in helper
     232                $featured_id = media_sideload_image(
     233                    $featured_url,
     234                    $post_id,
     235                    isset($featured_image['altText']) ? $featured_image['altText'] : '',
     236                    'id'
     237                );
     238            }
     239
    148240            if (!is_wp_error($featured_id)) {
    149                 // Set as featured image
    150241                set_post_thumbnail($post_id, $featured_id);
    151                
    152                 // Update alt text
     242
    153243                if (isset($featured_image['altText'])) {
    154244                    update_post_meta($featured_id, '_wp_attachment_image_alt', sanitize_text_field($featured_image['altText']));
    155245                }
    156                
    157                 // Store heading text
     246
    158247                if (isset($featured_image['headingText'])) {
    159248                    update_post_meta($featured_id, 'draftseo_heading_text', sanitize_text_field($featured_image['headingText']));
    160249                }
    161                
    162                 // Store URL hash for future lookup (enables deletion on republish)
    163                 self::store_image_hash($featured_id, $featured_image['url']);
    164                
    165                 // Store URL mapping
     250
     251                self::store_image_hash($featured_id, $featured_url);
     252
    166253                $wp_url = wp_get_attachment_url($featured_id);
    167                 $url_mapping[$featured_image['url']] = $wp_url;
    168             }
    169         }
    170        
    171         // Queue remaining images for background processing
     254                $url_mapping[$featured_url] = $wp_url;
     255            }
     256        }
     257
     258        // Queue remaining images for background processing via Action Scheduler.
     259        // AS runs the job as a proper background process — no page visit required.
    172260        $remaining_images = $set_featured ? array_slice($images, 1) : $images;
    173        
     261
    174262        if (!empty($remaining_images)) {
    175             // Schedule background processing using WordPress Cron
    176             wp_schedule_single_event(
    177                 time(),
    178                 'draftseo_process_images_background',
    179                 array(
    180                     'post_id' => $post_id,
    181                     'images' => $remaining_images
    182                 )
    183             );
    184         }
    185        
     263            if (function_exists('as_schedule_single_action')) {
     264                // Args wrapped in outer array so handler receives them as one $args array
     265                as_schedule_single_action(
     266                    time(),
     267                    'draftseo_process_images_background',
     268                    array(
     269                        array(
     270                            'post_id' => $post_id,
     271                            'images'  => $remaining_images,
     272                        )
     273                    ),
     274                    'draftseo-ai'
     275                );
     276            } else {
     277                // Fallback to WP Cron if Action Scheduler is unavailable
     278                wp_schedule_single_event(
     279                    time(),
     280                    'draftseo_process_images_background',
     281                    array(
     282                        'post_id' => $post_id,
     283                        'images'  => $remaining_images,
     284                    )
     285                );
     286                spawn_cron();
     287            }
     288        }
     289
    186290        return array(
    187             'imported_count' => $featured_id ? 1 : 0,
    188             'url_mapping' => $url_mapping,
    189             'queued_count' => count($remaining_images),
    190             'strategy' => 'hybrid'
     291            'imported_count' => $featured_id && !is_wp_error($featured_id) ? 1 : 0,
     292            'url_mapping'    => $url_mapping,
     293            'queued_count'   => count($remaining_images),
     294            'strategy'       => 'hybrid'
    191295        );
    192296    }
    193    
    194     /**
    195      * Background image processing handler
    196      *
    197      * Processes images queued for async download
    198      * Hooked to 'draftseo_process_images_background' action
    199      *
    200      * @param array $args Arguments (post_id, images)
     297
     298    /**
     299     * Background image processing handler (with callback)
     300     *
     301     * Processes ALL images queued via import_images_async(), replaces URLs in
     302     * post content, then POSTs the result to the DraftSEO callback URL.
     303     *
     304     * Hooked to 'draftseo_process_images_with_callback' action.
     305     * Scheduled via Action Scheduler — runs as a true background process.
     306     *
     307     * Action Scheduler retry behaviour: if this handler throws or times out,
     308     * AS will retry it automatically. Import is idempotent — find_existing_image()
     309     * detects already-imported URLs so retries skip completed work.
     310     *
     311     * @param array $args Arguments: post_id, images, set_featured, callback_url
     312     */
     313    public static function process_images_with_callback($args) {
     314        if (!isset($args['post_id']) || !isset($args['images']) || !isset($args['callback_url'])) {
     315            return;
     316        }
     317
     318        $post_id      = intval($args['post_id']);
     319        $images       = $args['images'];
     320        $set_featured = !empty($args['set_featured']);
     321        $callback_url = $args['callback_url'];
     322
     323        require_once(ABSPATH . 'wp-admin/includes/media.php');
     324        require_once(ABSPATH . 'wp-admin/includes/file.php');
     325        require_once(ABSPATH . 'wp-admin/includes/image.php');
     326
     327        $post_content   = get_post_field('post_content', $post_id);
     328        $url_mapping    = array();
     329        $imported_count = 0;
     330        $failed_count   = 0;
     331
     332        // Phase 1: Download all images in parallel using curl_multi.
     333        // This reduces download time from ~60-100 s (sequential) to ~5-15 s
     334        // for a typical 20-image blog.
     335        $tmp_map = self::parallel_download_images($images);
     336
     337        // Phase 2: Import each downloaded file into the Media Library sequentially.
     338        // Sequential import is required because WordPress thumbnail generation
     339        // (GD/ImageMagick) is CPU-bound and not safe to parallelise.
     340        foreach ($images as $index => $image) {
     341            if (empty($image['url'])) {
     342                continue;
     343            }
     344
     345            $url = esc_url_raw($image['url']);
     346
     347            if (!isset($tmp_map[$url])) {
     348                $failed_count++;
     349                continue; // Download failed for this URL
     350            }
     351
     352            // Skip if already imported — idempotent, safe on Action Scheduler retry
     353            if (self::find_existing_image($url)) {
     354                @unlink($tmp_map[$url]); // phpcs:ignore WordPress.PHP.NoSilencedErrors
     355                continue;
     356            }
     357
     358            $attachment_id = self::import_from_temp($tmp_map[$url], $url, $post_id, $image);
     359
     360            if (is_wp_error($attachment_id)) {
     361                $failed_count++;
     362                continue;
     363            }
     364
     365            // Set featured image (first image when requested)
     366            if ($set_featured && 0 === $index) {
     367                set_post_thumbnail($post_id, $attachment_id);
     368            }
     369
     370            $wp_url = wp_get_attachment_url($attachment_id);
     371            $url_mapping[$url] = $wp_url;
     372            $imported_count++;
     373        }
     374
     375        // Replace CDN URLs with WordPress local URLs in content
     376        if (!empty($url_mapping)) {
     377            $updated_content = self::replace_image_urls($post_content, $url_mapping);
     378            wp_update_post(array(
     379                'ID'           => $post_id,
     380                'post_content' => $updated_content
     381            ));
     382        }
     383
     384        // Report result back to DraftSEO
     385        self::fire_callback($callback_url, true, $imported_count, $failed_count);
     386    }
     387
     388    /**
     389     * Background image processing handler (no callback)
     390     *
     391     * Processes images queued by the hybrid strategy for async download.
     392     * Hooked to 'draftseo_process_images_background' action.
     393     * Scheduled via Action Scheduler — runs as a true background process.
     394     *
     395     * Action Scheduler retry behaviour: if this handler throws or times out,
     396     * AS will retry it automatically. Import is idempotent — find_existing_image()
     397     * detects already-imported URLs so retries skip completed work.
     398     *
     399     * @param array $args Arguments: post_id, images
    201400     */
    202401    public static function process_images_background($args) {
     
    204403            return;
    205404        }
    206        
     405
    207406        $post_id = intval($args['post_id']);
    208         $images = $args['images'];
    209        
     407        $images  = $args['images'];
     408
    210409        require_once(ABSPATH . 'wp-admin/includes/media.php');
    211410        require_once(ABSPATH . 'wp-admin/includes/file.php');
    212411        require_once(ABSPATH . 'wp-admin/includes/image.php');
    213        
     412
    214413        $post_content = get_post_field('post_content', $post_id);
    215         $url_mapping = array();
    216        
     414        $url_mapping  = array();
     415
     416        // Phase 1: Download all images in parallel using curl_multi.
     417        // This reduces download time from ~60-100 s (sequential) to ~5-15 s
     418        // for a typical 20-image blog.
     419        $tmp_map = self::parallel_download_images($images);
     420
     421        // Phase 2: Import each downloaded file into the Media Library sequentially.
     422        // Sequential import is required because WordPress thumbnail generation
     423        // (GD/ImageMagick) is CPU-bound and not safe to parallelise.
    217424        foreach ($images as $image) {
    218425            if (empty($image['url'])) {
    219426                continue;
    220427            }
    221            
    222             // Download from Nebius CDN
    223             $attachment_id = media_sideload_image(
    224                 $image['url'],
    225                 $post_id,
    226                 isset($image['altText']) ? $image['altText'] : '',
    227                 'id'
    228             );
    229            
     428
     429            $url = esc_url_raw($image['url']);
     430
     431            if (!isset($tmp_map[$url])) {
     432                continue; // Download failed for this URL — skip
     433            }
     434
     435            // Skip if already imported — idempotent, safe on Action Scheduler retry
     436            if (self::find_existing_image($url)) {
     437                @unlink($tmp_map[$url]); // phpcs:ignore WordPress.PHP.NoSilencedErrors
     438                continue;
     439            }
     440
     441            $attachment_id = self::import_from_temp($tmp_map[$url], $url, $post_id, $image);
     442
    230443            if (is_wp_error($attachment_id)) {
    231444                continue;
    232445            }
    233            
    234             // Update alt text
    235             if (isset($image['altText'])) {
    236                 update_post_meta($attachment_id, '_wp_attachment_image_alt', sanitize_text_field($image['altText']));
    237             }
    238            
    239             // Store heading text
    240             if (isset($image['headingText'])) {
    241                 update_post_meta($attachment_id, 'draftseo_heading_text', sanitize_text_field($image['headingText']));
    242             }
    243            
    244             // Store URL hash for future lookup (enables deletion on republish)
    245             self::store_image_hash($attachment_id, $image['url']);
    246            
    247             // Store URL mapping
     446
    248447            $wp_url = wp_get_attachment_url($attachment_id);
    249             $url_mapping[$image['url']] = $wp_url;
    250         }
    251        
    252         // Replace Nebius URLs with WordPress local URLs in content
     448            $url_mapping[$url] = $wp_url;
     449        }
     450
     451        // Replace CDN URLs with WordPress local URLs in content
    253452        if (!empty($url_mapping)) {
    254453            $updated_content = self::replace_image_urls($post_content, $url_mapping);
    255            
    256454            wp_update_post(array(
    257                 'ID' => $post_id,
     455                'ID'           => $post_id,
    258456                'post_content' => $updated_content
    259457            ));
    260458        }
    261459    }
    262    
     460
     461    /**
     462     * Download multiple images in parallel using curl_multi.
     463     *
     464     * Opens one cURL handle per image URL, runs all transfers simultaneously,
     465     * and writes each response body directly to a temp file. Returns a map of
     466     * url => temp_file_path for every URL that downloaded successfully.
     467     *
     468     * Falls back to sequential download via WordPress's download_url() on servers
     469     * where the cURL multi extension is unavailable (rare but possible).
     470     *
     471     * Only download is parallelised here. The subsequent Media Library import
     472     * (thumbnail generation via GD/ImageMagick) must remain sequential because
     473     * it is CPU-bound and not safe to parallelise on shared hosting.
     474     *
     475     * @param array $images    Array of image objects (each must have a 'url' key)
     476     * @param int   $timeout   Per-transfer timeout in seconds (default 30)
     477     * @return array           Map of url => temp_file_path for successful downloads
     478     */
     479    private static function parallel_download_images($images, $timeout = 30) {
     480        if (!function_exists('curl_multi_init')) {
     481            // cURL multi unavailable — fall back to sequential download via WordPress's
     482            // download_url(), which uses the WP HTTP API under the hood.
     483            return self::sequential_download_images($images, $timeout);
     484        }
     485
     486        $mh      = curl_multi_init();
     487        $handles = array(); // keyed by (int) curl handle resource
     488        $results = array(); // url => temp_file_path (successful downloads only)
     489
     490        foreach ($images as $image) {
     491            if (empty($image['url'])) {
     492                continue;
     493            }
     494
     495            $url = esc_url_raw($image['url']);
     496
     497            // Create a unique temp file for this download
     498            $tmp = wp_tempnam('draftseo-img-' . substr(md5($url), 0, 8));
     499            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
     500            $fp  = fopen($tmp, 'wb');
     501            if (!$fp) {
     502                @unlink($tmp); // phpcs:ignore WordPress.PHP.NoSilencedErrors
     503                continue;
     504            }
     505
     506            $ch = curl_init($url);
     507            curl_setopt_array($ch, array(
     508                CURLOPT_FILE           => $fp,
     509                CURLOPT_HEADER         => false,
     510                CURLOPT_FOLLOWLOCATION => true,
     511                CURLOPT_MAXREDIRS      => 5,
     512                CURLOPT_CONNECTTIMEOUT => 10,
     513                CURLOPT_TIMEOUT        => $timeout,
     514                CURLOPT_SSL_VERIFYPEER => true,
     515                // Identify as WordPress so CDNs don't block bot-like requests
     516                CURLOPT_USERAGENT      => 'WordPress/' . get_bloginfo('version') . '; ' . get_bloginfo('url'),
     517            ));
     518
     519            curl_multi_add_handle($mh, $ch);
     520
     521            // Store metadata by integer handle id for later lookup
     522            $handles[(int) $ch] = array(
     523                'ch'  => $ch,
     524                'url' => $url,
     525                'fp'  => $fp,
     526                'tmp' => $tmp,
     527            );
     528        }
     529
     530        if (empty($handles)) {
     531            curl_multi_close($mh);
     532            return array();
     533        }
     534
     535        // Non-blocking event loop — process transfers until all finish
     536        do {
     537            $status = curl_multi_exec($mh, $running);
     538            if ($running > 0) {
     539                // Block until activity on one of the sockets (reduces CPU spin)
     540                curl_multi_select($mh);
     541            }
     542        } while ($running > 0 && CURLM_OK === $status);
     543
     544        // Collect results: close file handles, check for errors, build result map
     545        foreach ($handles as $meta) {
     546            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
     547            fclose($meta['fp']);
     548
     549            $errno     = curl_errno($meta['ch']);
     550            $http_code = curl_getinfo($meta['ch'], CURLINFO_HTTP_CODE);
     551
     552            if (0 === $errno && $http_code >= 200 && $http_code < 300
     553                && file_exists($meta['tmp']) && filesize($meta['tmp']) > 0
     554            ) {
     555                // Download succeeded — include in results
     556                $results[$meta['url']] = $meta['tmp'];
     557            } else {
     558                // Download failed — discard temp file
     559                @unlink($meta['tmp']); // phpcs:ignore WordPress.PHP.NoSilencedErrors
     560            }
     561
     562            curl_multi_remove_handle($mh, $meta['ch']);
     563            curl_close($meta['ch']);
     564        }
     565
     566        curl_multi_close($mh);
     567        return $results;
     568    }
     569
     570    /**
     571     * Download images sequentially using WordPress's download_url().
     572     *
     573     * Fallback for servers where curl_multi_init() is unavailable.
     574     * Returns the same url => temp_file_path map as parallel_download_images()
     575     * so callers are unaware of which path was taken.
     576     *
     577     * @param array $images  Array of image objects (each must have a 'url' key)
     578     * @param int   $timeout Per-transfer timeout in seconds
     579     * @return array         Map of url => temp_file_path for successful downloads
     580     */
     581    private static function sequential_download_images($images, $timeout = 30) {
     582        $results = array();
     583
     584        foreach ($images as $image) {
     585            if (empty($image['url'])) {
     586                continue;
     587            }
     588
     589            $url = esc_url_raw($image['url']);
     590            $tmp = download_url($url, $timeout);
     591
     592            if (!is_wp_error($tmp) && file_exists($tmp) && filesize($tmp) > 0) {
     593                $results[$url] = $tmp;
     594            } elseif (!is_wp_error($tmp) && file_exists($tmp)) {
     595                @unlink($tmp); // phpcs:ignore WordPress.PHP.NoSilencedErrors
     596            }
     597        }
     598
     599        return $results;
     600    }
     601
     602    /**
     603     * Import a pre-downloaded temp file into the WordPress Media Library.
     604     *
     605     * Wraps media_handle_sideload() with metadata updates (alt text,
     606     * heading text, URL hash) so all callers share one consistent import path.
     607     *
     608     * @param string $tmp_path   Absolute path to the temp file on disk
     609     * @param string $url        Original CDN URL (used for hash storage)
     610     * @param int    $post_id    WordPress post to attach the image to
     611     * @param array  $image      Image object from DraftSEO (may contain altText, headingText)
     612     * @return int|WP_Error      Attachment ID on success, WP_Error on failure
     613     */
     614    private static function import_from_temp($tmp_path, $url, $post_id, $image) {
     615        $path_info = pathinfo(parse_url($url, PHP_URL_PATH));
     616        $filename  = sanitize_file_name(isset($path_info['basename']) ? $path_info['basename'] : 'image.jpg');
     617
     618        $file_info = wp_check_filetype($filename);
     619
     620        // Reject non-image MIME types before handing off to WordPress
     621        if (empty($file_info['type']) || 0 !== strpos($file_info['type'], 'image/')) {
     622            @unlink($tmp_path); // phpcs:ignore WordPress.PHP.NoSilencedErrors
     623            return new WP_Error('draftseo_invalid_mime', 'Not a valid image MIME type: ' . $url);
     624        }
     625
     626        $file_array = array(
     627            'name'     => $filename,
     628            'type'     => $file_info['type'],
     629            'tmp_name' => $tmp_path,
     630            'size'     => filesize($tmp_path),
     631            'error'    => 0,
     632        );
     633
     634        $title         = isset($image['altText']) ? $image['altText'] : '';
     635        $attachment_id = media_handle_sideload($file_array, $post_id, $title);
     636
     637        if (is_wp_error($attachment_id)) {
     638            @unlink($tmp_path); // phpcs:ignore WordPress.PHP.NoSilencedErrors
     639            return $attachment_id;
     640        }
     641
     642        // Persist alt text as WordPress image alt
     643        if (!empty($image['altText'])) {
     644            update_post_meta($attachment_id, '_wp_attachment_image_alt', sanitize_text_field($image['altText']));
     645        }
     646
     647        // Persist heading text for DraftSEO internal use
     648        if (!empty($image['headingText'])) {
     649            update_post_meta($attachment_id, 'draftseo_heading_text', sanitize_text_field($image['headingText']));
     650        }
     651
     652        // Store URL hash for duplicate detection and deletion on republish
     653        self::store_image_hash($attachment_id, $url);
     654
     655        return $attachment_id;
     656    }
     657
     658    /**
     659     * Fire the DraftSEO image-callback webhook.
     660     *
     661     * @param string $callback_url Signed DraftSEO callback URL
     662     * @param bool   $success      Whether image import succeeded overall
     663     * @param int    $imported     Number of images successfully imported
     664     * @param int    $failed       Number of images that failed to import
     665     */
     666    private static function fire_callback($callback_url, $success, $imported, $failed) {
     667        if (empty($callback_url)) {
     668            return;
     669        }
     670
     671        $body = wp_json_encode(array(
     672            'success'         => $success,
     673            'images_imported' => intval($imported),
     674            'images_failed'   => intval($failed),
     675        ));
     676
     677        wp_remote_post($callback_url, array(
     678            'headers'  => array('Content-Type' => 'application/json'),
     679            'body'     => $body,
     680            'timeout'  => 15,
     681            'blocking' => false, // Fire-and-forget
     682        ));
     683    }
     684
    263685    /**
    264686     * Replace image URLs in content
    265687     *
    266688     * @param string $content Post content
    267      * @param array $url_mapping Array of Nebius URL => WordPress URL mappings
     689     * @param array $url_mapping Array of CDN URL => WordPress URL mappings
    268690     * @return string Updated content
    269691     */
     
    272694            return $content;
    273695        }
    274        
    275         foreach ($url_mapping as $nebius_url => $wp_url) {
    276             $content = str_replace($nebius_url, $wp_url, $content);
    277         }
    278        
     696
     697        foreach ($url_mapping as $cdn_url => $wp_url) {
     698            $content = str_replace($cdn_url, $wp_url, $content);
     699        }
     700
    279701        return $content;
    280702    }
    281    
     703
    282704    /**
    283705     * Check if image already exists in media library (duplicate detection)
    284706     *
    285      * @param string $image_url Nebius CDN URL
     707     * @param string $image_url CDN URL
    286708     * @return int|false Attachment ID if exists, false otherwise
    287709     */
    288710    public static function find_existing_image($image_url) {
    289711        global $wpdb;
    290        
     712
    291713        // Generate hash of URL for comparison
    292714        $url_hash = md5($image_url);
    293        
     715
    294716        // Try to get from cache first
    295         $cache_key = 'draftseo_img_' . $url_hash;
     717        $cache_key     = 'draftseo_img_' . $url_hash;
    296718        $attachment_id = wp_cache_get($cache_key, 'draftseo_images');
    297        
     719
    298720        if (false === $attachment_id) {
    299721            // Not in cache, query database
     
    301723            $attachment_id = $wpdb->get_var(
    302724                $wpdb->prepare(
    303                     "SELECT post_id FROM {$wpdb->postmeta} 
    304                     WHERE meta_key = 'draftseo_image_url_hash' 
    305                     AND meta_value = %s 
     725                    "SELECT post_id FROM {$wpdb->postmeta}
     726                    WHERE meta_key = 'draftseo_image_url_hash'
     727                    AND meta_value = %s
    306728                    LIMIT 1",
    307729                    $url_hash
    308730                )
    309731            );
    310            
     732
    311733            // Store in cache (cache even if not found to avoid repeated queries)
    312734            wp_cache_set($cache_key, $attachment_id ? $attachment_id : 0, 'draftseo_images', 3600);
    313735        }
    314        
     736
    315737        return $attachment_id ? intval($attachment_id) : false;
    316738    }
    317    
     739
    318740    /**
    319741     * Delete WordPress media attachments by their original CDN source URLs
     
    328750            return;
    329751        }
    330        
     752
    331753        global $wpdb;
    332        
     754
    333755        foreach ($urls as $url) {
    334756            if (empty($url)) {
    335757                continue;
    336758            }
    337            
     759
    338760            $url = esc_url_raw($url);
    339            
     761
    340762            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    341763            $attachment_id = $wpdb->get_var(
    342764                $wpdb->prepare(
    343                     "SELECT post_id FROM {$wpdb->postmeta} 
    344                     WHERE meta_key = 'draftseo_original_url' 
    345                     AND meta_value = %s 
     765                    "SELECT post_id FROM {$wpdb->postmeta}
     766                    WHERE meta_key = 'draftseo_original_url'
     767                    AND meta_value = %s
    346768                    LIMIT 1",
    347769                    $url
    348770                )
    349771            );
    350            
     772
    351773            if ($attachment_id) {
    352774                // Delete attachment and its files from disk
    353775                wp_delete_attachment(intval($attachment_id), true);
    354                
     776
    355777                // Clear the URL hash cache entry
    356778                $cache_key = 'draftseo_img_' . md5($url);
     
    359781        }
    360782    }
    361    
     783
    362784    /**
    363785     * Store image URL hash for duplicate detection
    364786     *
    365787     * @param int $attachment_id WordPress attachment ID
    366      * @param string $image_url Nebius CDN URL
     788     * @param string $image_url CDN URL
    367789     */
    368790    public static function store_image_hash($attachment_id, $image_url) {
     
    373795}
    374796
    375 // Register background image processing hook
     797// Register background image processing hooks.
     798// Action Scheduler uses the standard WordPress add_action() system — these
     799// registrations work for both AS-dispatched and WP Cron-dispatched calls.
    376800add_action('draftseo_process_images_background', array('DraftSEO_Image_Handler', 'process_images_background'));
     801add_action('draftseo_process_images_with_callback', array('DraftSEO_Image_Handler', 'process_images_with_callback'));
  • draftseo-ai/trunk/includes/class-rest-api.php

    r3491200 r3493820  
    445445        }
    446446       
     447        // Store custom metadata (before image import so it's available even if images queue async)
     448        if (isset($params['wordCount'])) {
     449            update_post_meta($post_id, 'draftseo_word_count', intval($params['wordCount']));
     450        }
     451        if (isset($params['aiModel'])) {
     452            update_post_meta($post_id, 'draftseo_ai_model', sanitize_text_field($params['aiModel']));
     453        }
     454        if (isset($params['id'])) {
     455            update_post_meta($post_id, 'draftseo_blog_id', intval($params['id']));
     456        }
     457       
     458        // Store FAQ Schema data if provided
     459        if (isset($params['faqItems']) && is_array($params['faqItems']) && !empty($params['faqItems'])) {
     460            $faq_schema = self::build_faq_schema($params['faqItems']);
     461            update_post_meta($post_id, 'draftseo_faq_schema', wp_json_encode($faq_schema));
     462        }
     463
    447464        // Handle images
    448465        $images_imported = 0;
     466        $has_callback_url = isset($params['callbackUrl']) && !empty($params['callbackUrl']);
     467        $set_featured = isset($params['options']['setFeaturedImage']) && $params['options']['setFeaturedImage'];
     468
    449469        if (isset($params['images']) && is_array($params['images']) && !empty($params['images'])) {
    450             $set_featured = isset($params['options']['setFeaturedImage']) && $params['options']['setFeaturedImage'];
     470
     471            if ($has_callback_url) {
     472                // Async path: queue ALL images to WP Cron and return immediately.
     473                // DraftSEO's callback webhook will be fired when WP Cron finishes.
     474                // This prevents the connection-timeout error caused by long image
     475                // sideload operations blocking the HTTP response.
     476                $callback_url = esc_url_raw($params['callbackUrl']);
     477                DraftSEO_Image_Handler::import_images_async(
     478                    $params['images'],
     479                    $post_id,
     480                    $set_featured,
     481                    $callback_url
     482                );
     483
     484                self::log_publication($post_id, $params['id'] ?? null, 'success');
     485
     486                return rest_ensure_response(array(
     487                    'success'      => true,
     488                    'status'       => 'images_queued',
     489                    'post_id'      => $post_id,
     490                    'post_url'     => get_permalink($post_id),
     491                    'images_count' => count($params['images']),
     492                ));
     493            }
     494
     495            // Synchronous path (legacy / old plugin behaviour):
     496            // Process images inline, then update post content with local URLs.
    451497            $result = DraftSEO_Image_Handler::import_images($params['images'], $post_id, $set_featured);
    452498           
     
    478524        }
    479525       
    480         // Store custom metadata
    481         if (isset($params['wordCount'])) {
    482             update_post_meta($post_id, 'draftseo_word_count', intval($params['wordCount']));
    483         }
    484         if (isset($params['aiModel'])) {
    485             update_post_meta($post_id, 'draftseo_ai_model', sanitize_text_field($params['aiModel']));
    486         }
    487         if (isset($params['id'])) {
    488             update_post_meta($post_id, 'draftseo_blog_id', intval($params['id']));
    489         }
    490        
    491         // Store FAQ Schema data if provided
    492         if (isset($params['faqItems']) && is_array($params['faqItems']) && !empty($params['faqItems'])) {
    493             $faq_schema = self::build_faq_schema($params['faqItems']);
    494             update_post_meta($post_id, 'draftseo_faq_schema', wp_json_encode($faq_schema));
    495         }
    496        
    497526        // Log publication
    498527        self::log_publication($post_id, $params['id'] ?? null, 'success');
  • draftseo-ai/trunk/readme.txt

    r3491200 r3493820  
    55Tested up to: 6.9
    66Requires PHP: 7.4
    7 Stable tag: 1.1.2
     7Stable tag: 1.1.3
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    1313== Description ==
    1414
    15 DraftSEO.AI connects your WordPress site to the DraftSEO.AI platform, enabling you to publish AI-generated blog posts directly to your website with a single click.
     15Publish complete, AI-generated blog posts from DraftSEO.AI to your WordPress site in one click. Images are automatically transferred to your Media Library, meta descriptions and keywords are preserved, and categories and tags are applied — no copy-pasting, no manual uploads.
    1616
    1717= Key Features =
    1818
    1919* **One-Click Publishing** - Publish AI-generated blogs from DraftSEO.AI to WordPress instantly
    20 * **Automatic Image Import** - Images are automatically transferred from DraftSEO.ai to your WordPress Media Library
    21 * **SEO Optimization** - Maintains all SEO metadata, keywords, and meta descriptions
    22 * **Category & Tag Management** - Sync WordPress categories and automatically create tags from keywords
     20* **Automatic Image Import** - Images are automatically transferred from DraftSEO.AI to your WordPress Media Library
     21* **SEO Optimization** - All SEO metadata, meta descriptions, and keywords are transferred and applied
     22* **Category & Tag Management** - Sync WordPress categories and automatically create tags from blog keywords
    2323* **Multiple Publishing Options** - Save as draft, publish immediately, or schedule for later
    24 * **Content Cleanup** - Automatic HTML cleanup and formatting for WordPress compatibility
    25 * **Secure API Connection** - Encrypted API key storage using WordPress native encryption
    26 * **HMAC-SHA256 Webhook Security** - Industry-standard signature verification for deactivation and disconnect notifications
     24* **Clean HTML Output** - Published posts use proper heading tags, responsive table markup, and `rel="noopener noreferrer"` on all external links
     25* **Secure Connection** - Account credentials are encrypted at rest using AES-256-CBC and are never sent in plain text
     26* **Signed Disconnect Notifications** - When you deactivate or remove the plugin, your DraftSEO.AI account is notified via an HMAC-SHA256 signed request — no ghost connections left behind
    2727
    2828= How It Works =
    2929
    30301. Install and activate the plugin on your WordPress site
    31 2. Click "Connect with DraftSEO.ai" in plugin settings while you are logged in your DraftSEO.ai account
    32 3. Automatically connect using OAuth (no manual API key needed)
    33 4. Toggle Wordpress Auto-publish ON when generating blogs or click "Publish to WordPress" on already generated blogs
     312. Click "Connect with DraftSEO.ai" in plugin settings while you are logged in to your DraftSEO.ai account
     323. Connection completes automatically via OAuth — no API key needed
     334. Toggle WordPress Auto-publish ON when generating blogs, or click "Publish to WordPress" on any previously generated blog
    3434
    3535= Image Import =
    3636
    37 The plugin intelligently handles image import based on blog size:
    38 
    39 * **1-5 images**: Direct import (fast, 10-20 seconds)
    40 * **6+ images**: Hybrid approach - featured image imported immediately, remaining images processed in background via WordPress Cron
    41 
    42 All images are downloaded from DraftSEO.ai directly to your WordPress Media Library, ensuring full ownership and no external dependencies.
     37All images are downloaded directly from DraftSEO.AI to your WordPress Media Library — giving you full ownership with no ongoing external dependencies.
     38
     39* **1–5 images**: All images are downloaded in parallel and transfer immediately (typically 5–15 seconds)
     40* **6+ images**: The featured image is set right away; remaining images are downloaded in parallel and transferred in the background via Action Scheduler — no page visit required to start the background job
     41
     42**Requires an active DraftSEO.AI account.** Visit [draftseo.ai](https://draftseo.ai) to sign up.
    4343
    4444
     
    107107== Changelog ==
    108108
     109= 1.1.3 =
     110
     111Reliability improvements, faster image loading, and cleaner background processing.
     112
     113* Fixed: Images on published posts were sometimes not appearing on low-traffic websites — they were queued to download in the background but the background job wasn't starting until the next page visit. They now start immediately regardless of site traffic.
     114* Fixed: Switching the plugin off while a publish was in progress could leave background tasks running after deactivation. The plugin now cleanly stops all background jobs when it is deactivated.
     115* Fixed: Removing the plugin (uninstall) now properly disconnects your site from DraftSEO.AI — the connection is closed on the DraftSEO side before all plugin data is removed.
     116* Fixed: Plugin text was not translating correctly on WordPress sites running in a language other than English. Translation files now load properly.
     117* Improved: Background image jobs now use Action Scheduler instead of WP Cron. Action Scheduler runs as a true background process without requiring a page visit, retries failed jobs automatically, and shows pending and completed jobs in the WordPress admin at Tools > Scheduled Actions.
     118* Improved: Images in background jobs are now downloaded in parallel before being imported. For a blog with 20 images this reduces total download time from roughly 60–100 seconds (sequential) to 5–15 seconds (parallel).
     119* Fixed: Republishing a post was creating duplicate images in the Media Library for any image that had not changed. Only new or replaced images are now downloaded and imported — unchanged images are reused from the existing Media Library entry.
     120
    109121= 1.1.2 =
    110122
     
    113125= 1.1.0 =
    114126
    115 When a new image is generated and republished from DraftSEO.ai, the old now-unused image is auto-deleted from the Media folder in Wordpress. Saves storage space, cleans up unused media assets.
     127Automatic cleanup when republishing with a new image.
     128
     129* When you republish a post with a new AI-generated image, the new image is swapped in automatically and the previous image is removed from your WordPress Media Library — keeps your media folder clean and saves storage space.
    116130
    117131= 1.0.5 =
     
    137151= 1.0.2 =
    138152
    139 Content formatting and SEO structured data hotfix release.
    140 
    141 * In-text citations `[1]`, `[2]` now render as clickable superscript links that scroll to the matching reference
    142 * References section converted to a styled numbered list with anchor IDs for citation linking
    143 * All external links now open in a new tab with `rel="noopener noreferrer"` for security
    144 * FAQ Schema (JSON-LD) structured data extracted from content and injected into WordPress post `<head>` for Google rich results
    145 * WordPress plugin now injects front-end CSS for consistent styling of citations, references, and tables across themes
    146 * Updated content sanitization allowlist to support `<sup>`, `<ol>`/`<li>` with IDs, and `<a>` with `target`/`rel` attributes
    147 * WordPress site dropdown now only shows active/connected sites
     153Citation links, external link handling, and FAQ structured data for rich results.
     154
     155* In-text citations — `[1]`, `[2]` markers now render as clickable superscript links that jump to the matching reference in the References section
     156* References section — Converted to a numbered list with anchor IDs (`#ref-1`, `#ref-2`) for smooth in-page navigation
     157* External links — All external links now open in a new tab with `rel="noopener noreferrer"`
     158* FAQ structured data — FAQ question-answer pairs from blog content are injected as JSON-LD into the post `<head>` for Google FAQ rich results
     159* Theme-consistent styling — CSS is injected for citations, references, and tables so they display correctly across all WordPress themes
     160* Active sites filter — The WordPress site dropdown now shows only connected, active sites
    148161
    149162= 1.0.1 =
    150 * Hotfix: YouTube video embeds now render correctly using WordPress oEmbed
    151 * Hotfix: Data tables now display as formatted HTML tables instead of raw markdown text
     163
     164Hotfix for content rendering in published posts.
     165
     166* Fixed: YouTube embeds were being stripped during publishing — they now render as embedded players on your site
     167* Fixed: Data tables were displaying as raw Markdown text instead of formatted HTML tables
    152168
    153169
    154170= 1.0.0 =
    155171
    156 Major release with 30+ improvements across security, stability, performance, and API architecture.
    157 
    158 **Security (6 improvements)**
    159 
    160 * HMAC-SHA256 webhook authentication — Deactivation and disconnect webhooks now sign payloads with HMAC-SHA256 using the API key as the secret; the API key is never transmitted over the wire
    161 * Replay protection — Webhook requests include a Unix timestamp in `X-DraftSEO-Timestamp` header; requests older than 5 minutes are rejected
    162 * Timing-safe comparisons — All API key and signature comparisons use `hash_equals()` (PHP) and `crypto.timingSafeEqual` (Node.js) to prevent timing-based side-channel attacks
    163 * AES-256-CBC encryption — API keys stored at rest using AES-256-CBC with a random IV per encryption, derived from WordPress auth salt (site-specific, not hardcoded)
    164 * Improved deactivation hook — Now reads the API key via `DraftSEO_Settings::get_api_key()` (properly decrypted) for more reliable key handling
    165 * Enhanced key validation — `verify_api_key()` now explicitly validates both stored and provided keys with specific, actionable error codes
    166 
    167 **API & REST Endpoint Improvements (7 improvements)**
    168 
    169 * New `/tags` endpoint — Added `GET /wp-json/draftseo/v1/tags` for tag synchronization, matching the existing `/users` and `/categories` endpoints
    170 * Unified endpoint architecture — All three sync resources (users, categories, tags) now use the same plugin-first-then-fallback pattern via `fetchWithPluginFallback()`
    171 * Structured error responses — All error responses now use proper `WP_Error` objects with specific error codes (`rest_forbidden`, `rest_missing_param`, `rest_publish_error`, `rest_update_error`, `rest_post_not_found`, `rest_tags_error`) for better debugging and integration
    172 * `rest_ensure_response()` — All success responses now use `rest_ensure_response()` per WordPress REST API Handbook, allowing WordPress filters to process responses through the standard pipeline
    173 * Input validation arguments — `/publish` and `/update` routes now define `args` with `validate_callback` and `sanitize_callback` for server-side input validation before the handler runs
    174 * Remote disconnect endpoint — `/remote-disconnect` properly clears stored API key and connection settings when triggered from DraftSEO.AI platform
    175 * Bidirectional disconnect sync — When a user disconnects from DraftSEO.AI, the platform now calls the plugin's `/remote-disconnect` endpoint before local deletion, keeping both sides in sync
    176 
    177 **Stability & Error Handling (6 improvements)**
    178 
    179 * Non-JSON response resilience — Gracefully handles HTML maintenance pages, WAF blocks, and 503 errors from WordPress instead of failing silently
    180 * Sync endpoint timeout & abort — Added configurable timeout with AbortController to prevent hanging sync requests
    181 * Error isolation — Per-card Error Boundaries in the WordPress site list ensure individual site issues don't affect other connected sites
    182 * Guarded data access — All connection data property accesses use optional chaining with fallbacks for maximum reliability
    183 * Response validation — API responses are validated as proper arrays/objects before processing for robust data handling
    184 * Health check hardening — Health check response parsing improved with dedicated error paths for edge cases
    185 
    186 **Performance & Optimization (4 improvements)**
    187 
    188 * Parallel sync — Users, categories, and tags are fetched simultaneously via `Promise.all()` instead of sequentially
    189 * Smart retry logic — 4xx client errors (401, 403, 400, 422) skip retry entirely; only 5xx server errors are retried, reducing wasted API calls
    190 * Optimized cache invalidation — Streamlined cache invalidation strategy; added health check invalidation after sync for immediate UI updates
    191 * Image import strategy — Intelligent strategy selection: 1-5 images use direct import (fast), 6+ images use hybrid approach (featured image immediate, rest via WordPress Cron background processing)
     172Major release — security hardening, reliability improvements, and full tag management.
     173
     174**Security**
     175
     176* Webhook signatures — Disconnect and deactivation notifications are signed with HMAC-SHA256 (`X-DraftSEO-Signature`, `X-DraftSEO-Timestamp` headers); the API key is the signing secret and is never transmitted in plain text
     177* Replay protection — Signed requests include a Unix timestamp; requests older than 5 minutes are rejected
     178* API keys encrypted at rest — AES-256-CBC with a unique IV per key, derived from the WordPress site's auth salt
     179
     180**Publishing & REST API**
     181
     182* Tags endpoint — `GET /wp-json/draftseo/v1/tags` added for tag sync, matching the existing `/users` and `/categories` endpoints
     183* Server-side input validation — `/publish` and `/update` routes validate and sanitise all params before the handler runs
     184* Structured error responses — All errors return specific codes (`rest_forbidden`, `rest_missing_param`, `rest_publish_error`, etc.) for better debugging
     185* Bidirectional disconnect — Disconnecting from DraftSEO.AI calls `/remote-disconnect` to clear connection settings on the plugin side automatically
     186
     187**Reliability**
     188
     189* Handles security plugin blocks and maintenance pages gracefully — no silent failures when a WAF or caching layer intercepts requests
     190* Sync requests now have a timeout so connections never hang indefinitely
     191* Multi-site view: individual site connection errors are isolated so one broken connection does not affect others
     192
     193**Performance**
     194
     195* Users, categories, and tags are now fetched in parallel instead of sequentially
     196* Retries only fire on server errors (5xx) — client errors (4xx) fail immediately without wasting retry attempts
     197
     198**Tag Management**
     199
     200* Auto-create WordPress tags from AI-generated keywords at publish time (configurable, 1–10 tags)
     201* Select from existing WordPress tags, or create new ones on the fly during publishing
     202
     203**Image Handling**
     204
     205* All images downloaded directly to your WordPress Media Library
     206* Alt text from DraftSEO.AI preserved as WordPress image alt text
     207* Featured image set automatically; all image URLs in post content updated from DraftSEO.AI CDN to your local Media Library URLs
    192208
    193209**Usability**
    194210
    195 * Added "Settings" quick-access link on the Plugins page (next to Deactivate) for one-click access to plugin configuration
    196 
    197 **WordPress Best Practices**
    198 
    199 * Requires WordPress 6.2+ and PHP 7.4+
    200 * Follows WordPress Coding Standards (WPCS)
    201 * Uses `wp_kses_post()` for content sanitization
    202 * Nonces for admin AJAX security
    203 * Capability checks (`manage_options`) for settings access
    204 * Content cleanup: Markdown-to-HTML conversion, responsive table wrapping, blockquote formatting
    205 * Publication logging to custom database table
    206 * Image duplicate detection via URL hash with WordPress object cache
    207 
    208 **Tag Management**
    209 
    210 * Auto-create tags from AI-generated keywords (configurable 1-10 count)
    211 * Manual tag selection from existing WordPress tags
    212 * Custom tags: create new tags on-the-fly during publishing
    213 
    214 **Image Handling**
    215 
    216 * Direct download from DraftSEO.ai to WordPress Media Library
    217 * Alt text and heading text metadata preserved
    218 * Featured image setting with URL replacement in post content (DraftSEO.ai URLs → local WordPress URLs)
    219 * Background processing via WordPress Cron for large image sets (6+ images)
     211* "Settings" quick-link added to the Plugins page for faster access to plugin configuration
    220212
    221213= 0.2.0 =
    222 * Initial beta release
     214
     215Initial beta release.
     216
    223217* One-click blog publishing from DraftSEO.AI
    224218* Automatic image import from DraftSEO.ai
     
    235229== Upgrade Notice ==
    236230
     231= 1.1.3 =
     232Reliability improvements. Fixes images not appearing on low-traffic sites, background tasks not stopping cleanly on deactivation, and translations on non-English installs. Recommended for all users.
     233
    237234= 1.1.2 =
    238235Security update. Fixes API authentication failures on certain WordPress configurations that prevented DraftSEO.AI from syncing disconnect and deactivation events with your site.
    239236
    240237= 1.1.0 =
    241 When a new image is generated and republished from DraftSEO.ai, the old now-unused image is auto-deleted from the Media folder in Wordpress. Saves storage space, cleans up unused media assets.
     238When you republish a post with a new AI-generated image, the previous image is automatically removed from your WordPress Media Library. Saves storage space and keeps your media folder clean.
    242239
    243240= 1.0.5 =
    244 Youtube Player styles for blogs.
     241YouTube videos now display as embedded players on published WordPress posts.
    245242
    246243= 1.0.4 =
    247 Fixes YouTube videos not rendering on WordPress by correcting pipeline order and preserving Gutenberg block markers. Also fixes headings appearing as raw markdown after images.
     244Fixes headings appearing as plain text after images in published posts. Also removes unwanted image captions that were displaying as visible text below every image.
    248245
    249246= 1.0.3 =
  • draftseo-ai/trunk/uninstall.php

    r3423447 r3493820  
    33 * Plugin Uninstall Handler
    44 *
    5  * Fires when the plugin is uninstalled via WordPress admin
     5 * Fires when the plugin is deleted via WordPress admin.
     6 *
     7 * IMPORTANT ORDER OF OPERATIONS:
     8 *   1. Notify DraftSEO FIRST (while credentials still exist so the webhook is authenticated)
     9 *   2. Delete all plugin data second
     10 *
     11 * Unlike deactivation (which is triggered inside a running page request and can
     12 * safely use blocking => false), uninstall.php runs in a short-lived process.
     13 * Non-blocking requests may not flush before PHP exits, so the notify call here
     14 * uses blocking => true with a short timeout.
    615 *
    716 * @package DraftSEO_Publisher
    8  * @since 1.0.0
     17 * @since 1.1.3
    918 */
    1019
    1120// Exit if accessed directly or not uninstalling
    12 if (!defined('WP_UNINSTALL_PLUGIN')) {
     21if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    1322    exit;
    1423}
    1524
     25// Load the settings class so we can access encrypted credentials without
     26// including the full plugin bootstrap (which is not loaded in uninstall context).
     27require_once plugin_dir_path( __FILE__ ) . 'includes/class-settings.php';
     28
    1629/**
    17  * Clean up plugin data on uninstall
     30 * Send a signed disconnect webhook to DraftSEO before credentials are deleted.
     31 *
     32 * Uses blocking => true with a short timeout to guarantee the HTTP request
     33 * completes before the uninstall process wipes the credentials. A failed
     34 * request (network error, timeout) is silently ignored — the uninstall
     35 * continues regardless.
     36 */
     37function draftseo_notify_uninstall() {
     38    $api_key      = DraftSEO_Settings::get_api_key();
     39    $platform_url = DraftSEO_Settings::get_platform_url();
     40
     41    // Plugin was never connected — nothing to notify.
     42    if ( empty( $api_key ) || empty( $platform_url ) ) {
     43        return;
     44    }
     45
     46    $timestamp   = time();
     47    $payload     = array(
     48        'site_url'  => get_site_url(),
     49        'reason'    => 'uninstalled',
     50        'timestamp' => $timestamp,
     51    );
     52    $body_json   = wp_json_encode( $payload );
     53    $signature   = hash_hmac( 'sha256', $body_json, $api_key );
     54    $webhook_url = rtrim( $platform_url, '/' ) . '/api/wordpress/deactivate-webhook';
     55
     56    // blocking => true: ensures the HTTP data is fully sent before PHP exits.
     57    // timeout => 5: short enough not to delay the uninstall noticeably.
     58    wp_remote_post( $webhook_url, array(
     59        'timeout'   => 5,
     60        'blocking'  => true,
     61        'sslverify' => true,
     62        'headers'   => array(
     63            'Content-Type'         => 'application/json',
     64            'X-DraftSEO-Signature' => $signature,
     65            'X-DraftSEO-Timestamp' => (string) $timestamp,
     66        ),
     67        'body'      => $body_json,
     68    ) );
     69}
     70
     71/**
     72 * Clean up all plugin data on uninstall.
    1873 */
    1974function draftseo_publisher_uninstall() {
    2075    global $wpdb;
    21    
    22     // Delete plugin options
    23     delete_option('draftseo_settings');
    24     delete_option('draftseo_api_key_encrypted');
    25     delete_option('draftseo_platform_url');
    26    
    27     // Delete custom database table
     76
     77    // 1. Notify DraftSEO BEFORE deleting credentials so the webhook can be authenticated.
     78    draftseo_notify_uninstall();
     79
     80    // 2. Delete plugin options.
     81    delete_option( 'draftseo_settings' );
     82    delete_option( 'draftseo_api_key_encrypted' );
     83    delete_option( 'draftseo_platform_url' );
     84    delete_option( 'draftseo_activation_redirect' );
     85
     86    // 3. Drop publication logs table.
    2887    $table_name = $wpdb->prefix . 'draftseo_logs';
    2988    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
    30     $wpdb->query("DROP TABLE IF EXISTS " . esc_sql($table_name));
    31    
    32     // Delete all post meta created by the plugin
     89    $wpdb->query( 'DROP TABLE IF EXISTS ' . esc_sql( $table_name ) );
     90
     91    // 4. Delete all post meta created by the plugin.
    3392    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    34     $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE %s", 'draftseo_%'));
    35    
    36     // Clear scheduled cron events
    37     $timestamp = wp_next_scheduled('draftseo_process_images_background');
    38     if ($timestamp) {
    39         wp_unschedule_event($timestamp, 'draftseo_process_images_background');
     93    $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE %s", 'draftseo_%' ) );
     94
     95    // 5. Cancel all pending Action Scheduler jobs (added in 1.1.3).
     96    // as_unschedule_all_actions() removes every queued occurrence of a hook.
     97    if ( function_exists( 'as_unschedule_all_actions' ) ) {
     98        as_unschedule_all_actions( 'draftseo_process_images_background', array(), 'draftseo-ai' );
     99        as_unschedule_all_actions( 'draftseo_process_images_with_callback', array(), 'draftseo-ai' );
    40100    }
    41    
    42     // Optional: Remove imported images (DANGEROUS - commented out by default)
    43     // Uncomment the following lines ONLY if you want to delete all imported images on uninstall
    44     /*
    45     $attachment_ids = $wpdb->get_col(
    46         "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = 'draftseo_image_url_hash'"
    47     );
    48    
    49     foreach ($attachment_ids as $attachment_id) {
    50         wp_delete_attachment($attachment_id, true);
    51     }
    52     */
     101
     102    // Also clear any legacy WP Cron events from plugin versions < 1.1.3.
     103    // wp_clear_scheduled_hook removes every pending occurrence of a hook,
     104    // not just the next one — correct approach for full cleanup.
     105    wp_clear_scheduled_hook( 'draftseo_process_images_background' );
     106    wp_clear_scheduled_hook( 'draftseo_process_images_with_callback' );
     107
     108    // NOTE: Do NOT delete published WordPress posts or imported media attachments.
     109    // Those belong to the site owner, not to the DraftSEO plugin.
    53110}
    54111
    55 // Run uninstall
     112// Run uninstall.
    56113draftseo_publisher_uninstall();
Note: See TracChangeset for help on using the changeset viewer.