Changeset 3493820
- Timestamp:
- 03/29/2026 12:38:18 PM (3 days ago)
- Location:
- draftseo-ai
- Files:
-
- 230 added
- 6 edited
-
tags/1.1.3 (added)
-
tags/1.1.3/LICENSE.txt (added)
-
tags/1.1.3/README.md (added)
-
tags/1.1.3/admin (added)
-
tags/1.1.3/admin/css (added)
-
tags/1.1.3/admin/css/admin-styles.css (added)
-
tags/1.1.3/admin/css/index.php (added)
-
tags/1.1.3/admin/index.php (added)
-
tags/1.1.3/admin/js (added)
-
tags/1.1.3/admin/js/admin-scripts.js (added)
-
tags/1.1.3/admin/js/index.php (added)
-
tags/1.1.3/admin/settings-page.php (added)
-
tags/1.1.3/composer.json (added)
-
tags/1.1.3/draftseo-ai.php (added)
-
tags/1.1.3/includes (added)
-
tags/1.1.3/includes/class-api-client.php (added)
-
tags/1.1.3/includes/class-content-processor.php (added)
-
tags/1.1.3/includes/class-image-handler.php (added)
-
tags/1.1.3/includes/class-rest-api.php (added)
-
tags/1.1.3/includes/class-seo-handler.php (added)
-
tags/1.1.3/includes/class-settings.php (added)
-
tags/1.1.3/includes/index.php (added)
-
tags/1.1.3/index.php (added)
-
tags/1.1.3/languages (added)
-
tags/1.1.3/languages/index.php (added)
-
tags/1.1.3/readme.txt (added)
-
tags/1.1.3/uninstall.php (added)
-
tags/1.1.3/vendor (added)
-
tags/1.1.3/vendor/action-scheduler (added)
-
tags/1.1.3/vendor/action-scheduler/README.md (added)
-
tags/1.1.3/vendor/action-scheduler/action-scheduler.php (added)
-
tags/1.1.3/vendor/action-scheduler/changelog.txt (added)
-
tags/1.1.3/vendor/action-scheduler/classes (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_ActionClaim.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_ActionFactory.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_AdminView.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_AsyncRequest_QueueRunner.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_Compatibility.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_DataController.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_DateTime.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_Exception.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_FatalErrorMonitor.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_InvalidActionException.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_ListTable.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_LogEntry.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_NullLogEntry.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_OptionLock.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_QueueCleaner.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_QueueRunner.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_Versions.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_WPCommentCleaner.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/ActionScheduler_wcSystemStatus.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/WP_CLI (added)
-
tags/1.1.3/vendor/action-scheduler/classes/WP_CLI/ActionScheduler_WPCLI_Clean_Command.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/WP_CLI/ActionScheduler_WPCLI_QueueRunner.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/WP_CLI/ActionScheduler_WPCLI_Scheduler_command.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/WP_CLI/Migration_Command.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/WP_CLI/ProgressBar.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/abstracts (added)
-
tags/1.1.3/vendor/action-scheduler/classes/abstracts/ActionScheduler.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/abstracts/ActionScheduler_Abstract_ListTable.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/abstracts/ActionScheduler_Abstract_QueueRunner.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/abstracts/ActionScheduler_Abstract_RecurringSchedule.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/abstracts/ActionScheduler_Abstract_Schedule.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/abstracts/ActionScheduler_Abstract_Schema.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/abstracts/ActionScheduler_Lock.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/abstracts/ActionScheduler_Logger.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/abstracts/ActionScheduler_Store.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/abstracts/ActionScheduler_TimezoneHelper.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/actions (added)
-
tags/1.1.3/vendor/action-scheduler/classes/actions/ActionScheduler_Action.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/actions/ActionScheduler_CanceledAction.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/actions/ActionScheduler_FinishedAction.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/actions/ActionScheduler_NullAction.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/data-stores (added)
-
tags/1.1.3/vendor/action-scheduler/classes/data-stores/ActionScheduler_DBLogger.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/data-stores/ActionScheduler_DBStore.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/data-stores/ActionScheduler_HybridStore.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/data-stores/ActionScheduler_wpCommentLogger.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/data-stores/ActionScheduler_wpPostStore.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/data-stores/ActionScheduler_wpPostStore_PostStatusRegistrar.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/data-stores/ActionScheduler_wpPostStore_PostTypeRegistrar.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/data-stores/ActionScheduler_wpPostStore_TaxonomyRegistrar.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/migration (added)
-
tags/1.1.3/vendor/action-scheduler/classes/migration/ActionMigrator.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/migration/ActionScheduler_DBStoreMigrator.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/migration/BatchFetcher.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/migration/Config.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/migration/Controller.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/migration/DryRun_ActionMigrator.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/migration/DryRun_LogMigrator.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/migration/LogMigrator.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/migration/Runner.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/migration/Scheduler.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/schedules (added)
-
tags/1.1.3/vendor/action-scheduler/classes/schedules/ActionScheduler_CanceledSchedule.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/schedules/ActionScheduler_CronSchedule.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/schedules/ActionScheduler_IntervalSchedule.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/schedules/ActionScheduler_NullSchedule.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/schedules/ActionScheduler_Schedule.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/schedules/ActionScheduler_SimpleSchedule.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/schema (added)
-
tags/1.1.3/vendor/action-scheduler/classes/schema/ActionScheduler_LoggerSchema.php (added)
-
tags/1.1.3/vendor/action-scheduler/classes/schema/ActionScheduler_StoreSchema.php (added)
-
tags/1.1.3/vendor/action-scheduler/deprecated (added)
-
tags/1.1.3/vendor/action-scheduler/deprecated/ActionScheduler_Abstract_QueueRunner_Deprecated.php (added)
-
tags/1.1.3/vendor/action-scheduler/deprecated/ActionScheduler_AdminView_Deprecated.php (added)
-
tags/1.1.3/vendor/action-scheduler/deprecated/ActionScheduler_Schedule_Deprecated.php (added)
-
tags/1.1.3/vendor/action-scheduler/deprecated/ActionScheduler_Store_Deprecated.php (added)
-
tags/1.1.3/vendor/action-scheduler/deprecated/functions.php (added)
-
tags/1.1.3/vendor/action-scheduler/functions.php (added)
-
tags/1.1.3/vendor/action-scheduler/lib (added)
-
tags/1.1.3/vendor/action-scheduler/lib/WP_Async_Request.php (added)
-
tags/1.1.3/vendor/action-scheduler/lib/cron-expression (added)
-
tags/1.1.3/vendor/action-scheduler/lib/cron-expression/CronExpression.php (added)
-
tags/1.1.3/vendor/action-scheduler/lib/cron-expression/CronExpression_AbstractField.php (added)
-
tags/1.1.3/vendor/action-scheduler/lib/cron-expression/CronExpression_DayOfMonthField.php (added)
-
tags/1.1.3/vendor/action-scheduler/lib/cron-expression/CronExpression_DayOfWeekField.php (added)
-
tags/1.1.3/vendor/action-scheduler/lib/cron-expression/CronExpression_FieldFactory.php (added)
-
tags/1.1.3/vendor/action-scheduler/lib/cron-expression/CronExpression_FieldInterface.php (added)
-
tags/1.1.3/vendor/action-scheduler/lib/cron-expression/CronExpression_HoursField.php (added)
-
tags/1.1.3/vendor/action-scheduler/lib/cron-expression/CronExpression_MinutesField.php (added)
-
tags/1.1.3/vendor/action-scheduler/lib/cron-expression/CronExpression_MonthField.php (added)
-
tags/1.1.3/vendor/action-scheduler/lib/cron-expression/CronExpression_YearField.php (added)
-
tags/1.1.3/vendor/action-scheduler/lib/cron-expression/LICENSE (added)
-
tags/1.1.3/vendor/action-scheduler/lib/cron-expression/README.md (added)
-
tags/1.1.3/vendor/action-scheduler/license.txt (added)
-
tags/1.1.3/vendor/action-scheduler/readme.txt (added)
-
trunk/README.md (modified) (5 diffs)
-
trunk/composer.json (added)
-
trunk/draftseo-ai.php (modified) (5 diffs)
-
trunk/includes/class-image-handler.php (modified) (11 diffs)
-
trunk/includes/class-rest-api.php (modified) (2 diffs)
-
trunk/readme.txt (modified) (6 diffs)
-
trunk/uninstall.php (modified) (1 diff)
-
trunk/vendor (added)
-
trunk/vendor/action-scheduler (added)
-
trunk/vendor/action-scheduler/README.md (added)
-
trunk/vendor/action-scheduler/action-scheduler.php (added)
-
trunk/vendor/action-scheduler/changelog.txt (added)
-
trunk/vendor/action-scheduler/classes (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_ActionClaim.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_ActionFactory.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_AdminView.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_AsyncRequest_QueueRunner.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_Compatibility.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_DataController.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_DateTime.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_Exception.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_FatalErrorMonitor.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_InvalidActionException.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_ListTable.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_LogEntry.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_NullLogEntry.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_OptionLock.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_QueueCleaner.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_QueueRunner.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_Versions.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_WPCommentCleaner.php (added)
-
trunk/vendor/action-scheduler/classes/ActionScheduler_wcSystemStatus.php (added)
-
trunk/vendor/action-scheduler/classes/WP_CLI (added)
-
trunk/vendor/action-scheduler/classes/WP_CLI/ActionScheduler_WPCLI_Clean_Command.php (added)
-
trunk/vendor/action-scheduler/classes/WP_CLI/ActionScheduler_WPCLI_QueueRunner.php (added)
-
trunk/vendor/action-scheduler/classes/WP_CLI/ActionScheduler_WPCLI_Scheduler_command.php (added)
-
trunk/vendor/action-scheduler/classes/WP_CLI/Migration_Command.php (added)
-
trunk/vendor/action-scheduler/classes/WP_CLI/ProgressBar.php (added)
-
trunk/vendor/action-scheduler/classes/abstracts (added)
-
trunk/vendor/action-scheduler/classes/abstracts/ActionScheduler.php (added)
-
trunk/vendor/action-scheduler/classes/abstracts/ActionScheduler_Abstract_ListTable.php (added)
-
trunk/vendor/action-scheduler/classes/abstracts/ActionScheduler_Abstract_QueueRunner.php (added)
-
trunk/vendor/action-scheduler/classes/abstracts/ActionScheduler_Abstract_RecurringSchedule.php (added)
-
trunk/vendor/action-scheduler/classes/abstracts/ActionScheduler_Abstract_Schedule.php (added)
-
trunk/vendor/action-scheduler/classes/abstracts/ActionScheduler_Abstract_Schema.php (added)
-
trunk/vendor/action-scheduler/classes/abstracts/ActionScheduler_Lock.php (added)
-
trunk/vendor/action-scheduler/classes/abstracts/ActionScheduler_Logger.php (added)
-
trunk/vendor/action-scheduler/classes/abstracts/ActionScheduler_Store.php (added)
-
trunk/vendor/action-scheduler/classes/abstracts/ActionScheduler_TimezoneHelper.php (added)
-
trunk/vendor/action-scheduler/classes/actions (added)
-
trunk/vendor/action-scheduler/classes/actions/ActionScheduler_Action.php (added)
-
trunk/vendor/action-scheduler/classes/actions/ActionScheduler_CanceledAction.php (added)
-
trunk/vendor/action-scheduler/classes/actions/ActionScheduler_FinishedAction.php (added)
-
trunk/vendor/action-scheduler/classes/actions/ActionScheduler_NullAction.php (added)
-
trunk/vendor/action-scheduler/classes/data-stores (added)
-
trunk/vendor/action-scheduler/classes/data-stores/ActionScheduler_DBLogger.php (added)
-
trunk/vendor/action-scheduler/classes/data-stores/ActionScheduler_DBStore.php (added)
-
trunk/vendor/action-scheduler/classes/data-stores/ActionScheduler_HybridStore.php (added)
-
trunk/vendor/action-scheduler/classes/data-stores/ActionScheduler_wpCommentLogger.php (added)
-
trunk/vendor/action-scheduler/classes/data-stores/ActionScheduler_wpPostStore.php (added)
-
trunk/vendor/action-scheduler/classes/data-stores/ActionScheduler_wpPostStore_PostStatusRegistrar.php (added)
-
trunk/vendor/action-scheduler/classes/data-stores/ActionScheduler_wpPostStore_PostTypeRegistrar.php (added)
-
trunk/vendor/action-scheduler/classes/data-stores/ActionScheduler_wpPostStore_TaxonomyRegistrar.php (added)
-
trunk/vendor/action-scheduler/classes/migration (added)
-
trunk/vendor/action-scheduler/classes/migration/ActionMigrator.php (added)
-
trunk/vendor/action-scheduler/classes/migration/ActionScheduler_DBStoreMigrator.php (added)
-
trunk/vendor/action-scheduler/classes/migration/BatchFetcher.php (added)
-
trunk/vendor/action-scheduler/classes/migration/Config.php (added)
-
trunk/vendor/action-scheduler/classes/migration/Controller.php (added)
-
trunk/vendor/action-scheduler/classes/migration/DryRun_ActionMigrator.php (added)
-
trunk/vendor/action-scheduler/classes/migration/DryRun_LogMigrator.php (added)
-
trunk/vendor/action-scheduler/classes/migration/LogMigrator.php (added)
-
trunk/vendor/action-scheduler/classes/migration/Runner.php (added)
-
trunk/vendor/action-scheduler/classes/migration/Scheduler.php (added)
-
trunk/vendor/action-scheduler/classes/schedules (added)
-
trunk/vendor/action-scheduler/classes/schedules/ActionScheduler_CanceledSchedule.php (added)
-
trunk/vendor/action-scheduler/classes/schedules/ActionScheduler_CronSchedule.php (added)
-
trunk/vendor/action-scheduler/classes/schedules/ActionScheduler_IntervalSchedule.php (added)
-
trunk/vendor/action-scheduler/classes/schedules/ActionScheduler_NullSchedule.php (added)
-
trunk/vendor/action-scheduler/classes/schedules/ActionScheduler_Schedule.php (added)
-
trunk/vendor/action-scheduler/classes/schedules/ActionScheduler_SimpleSchedule.php (added)
-
trunk/vendor/action-scheduler/classes/schema (added)
-
trunk/vendor/action-scheduler/classes/schema/ActionScheduler_LoggerSchema.php (added)
-
trunk/vendor/action-scheduler/classes/schema/ActionScheduler_StoreSchema.php (added)
-
trunk/vendor/action-scheduler/deprecated (added)
-
trunk/vendor/action-scheduler/deprecated/ActionScheduler_Abstract_QueueRunner_Deprecated.php (added)
-
trunk/vendor/action-scheduler/deprecated/ActionScheduler_AdminView_Deprecated.php (added)
-
trunk/vendor/action-scheduler/deprecated/ActionScheduler_Schedule_Deprecated.php (added)
-
trunk/vendor/action-scheduler/deprecated/ActionScheduler_Store_Deprecated.php (added)
-
trunk/vendor/action-scheduler/deprecated/functions.php (added)
-
trunk/vendor/action-scheduler/functions.php (added)
-
trunk/vendor/action-scheduler/lib (added)
-
trunk/vendor/action-scheduler/lib/WP_Async_Request.php (added)
-
trunk/vendor/action-scheduler/lib/cron-expression (added)
-
trunk/vendor/action-scheduler/lib/cron-expression/CronExpression.php (added)
-
trunk/vendor/action-scheduler/lib/cron-expression/CronExpression_AbstractField.php (added)
-
trunk/vendor/action-scheduler/lib/cron-expression/CronExpression_DayOfMonthField.php (added)
-
trunk/vendor/action-scheduler/lib/cron-expression/CronExpression_DayOfWeekField.php (added)
-
trunk/vendor/action-scheduler/lib/cron-expression/CronExpression_FieldFactory.php (added)
-
trunk/vendor/action-scheduler/lib/cron-expression/CronExpression_FieldInterface.php (added)
-
trunk/vendor/action-scheduler/lib/cron-expression/CronExpression_HoursField.php (added)
-
trunk/vendor/action-scheduler/lib/cron-expression/CronExpression_MinutesField.php (added)
-
trunk/vendor/action-scheduler/lib/cron-expression/CronExpression_MonthField.php (added)
-
trunk/vendor/action-scheduler/lib/cron-expression/CronExpression_YearField.php (added)
-
trunk/vendor/action-scheduler/lib/cron-expression/LICENSE (added)
-
trunk/vendor/action-scheduler/lib/cron-expression/README.md (added)
-
trunk/vendor/action-scheduler/license.txt (added)
-
trunk/vendor/action-scheduler/readme.txt (added)
Legend:
- Unmodified
- Added
- Removed
-
draftseo-ai/trunk/README.md
r3491200 r3493820 47 47 The plugin intelligently handles images based on blog size: 48 48 49 - **1-5 images**: Direct import (10-20seconds)49 - **1-5 images**: Direct import — all images downloaded in parallel and imported immediately (typically 5–15 seconds) 50 50 - **6+ images**: Hybrid approach 51 51 - Featured image imported immediately 52 - Remaining images processed in background via WordPress Cron52 - Remaining images downloaded in parallel and imported in the background via Action Scheduler (no page visit needed) 53 53 54 54 All images are downloaded directly from DraftSEO.ai to your WordPress Media Library. … … 173 173 ## Changelog 174 174 175 ### 1.1.3 176 177 Reliability 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 175 187 ### 1.1.2 176 188 … … 179 191 ### 1.1.0 180 192 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. 193 Automatic 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 183 197 184 198 ### 1.0.5 … … 208 222 - **In-text citations** — `[1]`, `[2]` markers now render as clickable superscript links that scroll to the matching reference in the References section 209 223 - **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 UX211 - **FAQ Schema (JSON-LD)** — FAQ question-answer pairs are extracted from blog content and injected as structured data in the WordPresspost `<head>` for Google FAQ rich results212 - ** Front-end CSS** — Plugin now injects lightweight CSS for consistent styling of citations, references, and tablesacross all WordPress themes213 - ** Content sanitization** — Updated `wp_kses` allowlist to support `<sup>`, `<ol>`/`<li>` with `id`/`value`/`class`, and `<a>` with `target`/`rel` attributes224 - **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 214 228 - **Active sites filter** — WordPress site dropdown now only shows active/connected sites 215 229 … … 218 232 Hotfix for content rendering in published posts. 219 233 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 224 236 225 237 ### 1.0.0 226 238 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) 239 Major 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 259 269 260 270 #### 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 283 272 284 273 ### 0.2.0 (Initial Beta) -
draftseo-ai/trunk/draftseo-ai.php
r3491200 r3493820 3 3 * Plugin Name: DraftSEO.AI 4 4 * Plugin URI: https://draftseo.ai/wp-plugin 5 * Description: Publish AI-generated blogs from DraftSEO.AI platform directly to WordPress. Transfers images from Nebius CDNto WordPress media library while maintaining SEO optimization.6 * Version: 1.1. 25 * 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 7 7 * Author: DraftSEO.AI 8 8 * Author URI: https://draftseo.ai … … 38 38 39 39 // Define plugin constants 40 define('DRAFTSEO_VERSION', '1.1. 2');40 define('DRAFTSEO_VERSION', '1.1.3'); 41 41 define('DRAFTSEO_PLUGIN_DIR', plugin_dir_path(__FILE__)); 42 42 define('DRAFTSEO_PLUGIN_URL', plugin_dir_url(__FILE__)); 43 43 define('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. 48 if (file_exists(DRAFTSEO_PLUGIN_DIR . 'vendor/action-scheduler/action-scheduler.php')) { 49 require_once DRAFTSEO_PLUGIN_DIR . 'vendor/action-scheduler/action-scheduler.php'; 50 } 44 51 45 52 /** … … 91 98 */ 92 99 private function init_hooks() { 100 // Load plugin text domain for translations 101 add_action('plugins_loaded', array($this, 'load_textdomain')); 102 93 103 // Activation and deactivation hooks 94 104 register_activation_hook(__FILE__, array($this, 'activate')); … … 117 127 } 118 128 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 119 140 /** 120 141 * Add action links to the plugin listing on the Plugins page … … 158 179 // Notify DraftSEO.AI platform about plugin deactivation 159 180 $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 167 195 // Flush rewrite rules 168 196 flush_rewrite_rules(); -
draftseo-ai/trunk/includes/class-image-handler.php
r3478117 r3493820 3 3 * Image Handler Class 4 4 * 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. 7 17 * 8 18 * @package DraftSEO_Publisher … … 16 26 17 27 class DraftSEO_Image_Handler { 18 19 /** 20 * Import images from NebiusCDN28 29 /** 30 * Import images from DraftSEO CDN 21 31 * 22 32 * 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) 25 35 * 26 36 * @param array $images Array of image objects from DraftSEO.AI … … 36 46 ); 37 47 } 38 48 39 49 $image_count = count($images); 40 50 41 51 // Strategy selection based on image count 42 52 if ($image_count <= 5) { 43 // Strategy 1: Direct import 53 // Strategy 1: Direct import (curl_multi parallel download) 44 54 return self::import_images_direct($images, $post_id, $set_featured); 45 55 } else { 46 // Strategy 3: Hybrid (featured immediate + rest async)56 // Strategy 3: Hybrid (featured immediate + rest via Action Scheduler) 47 57 return self::import_images_hybrid($images, $post_id, $set_featured); 48 58 } 49 59 } 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. 55 132 * 56 133 * @param array $images Array of image objects … … 63 140 require_once(ABSPATH . 'wp-admin/includes/file.php'); 64 141 require_once(ABSPATH . 'wp-admin/includes/image.php'); 65 142 66 143 $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 69 150 foreach ($images as $index => $image) { 70 151 if (empty($image['url'])) { 71 152 continue; 72 153 } 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 82 178 if (is_wp_error($attachment_id)) { 83 179 continue; 84 180 } 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 96 182 // Set as featured image (first image only) 97 if ($set_featured && $index === 0) {183 if ($set_featured && 0 === $index) { 98 184 set_post_thumbnail($post_id, $attachment_id); 99 185 } 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 105 187 $wp_url = wp_get_attachment_url($attachment_id); 106 $url_mapping[$image['url']] = $wp_url; 107 188 $url_mapping[$url] = $wp_url; 108 189 $imported_count++; 109 190 } 110 191 111 192 return array( 112 193 'imported_count' => $imported_count, 113 'url_mapping' => $url_mapping,114 'strategy' => 'direct'194 'url_mapping' => $url_mapping, 195 'strategy' => 'direct' 115 196 ); 116 197 } 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. 123 207 * 124 208 * @param array $images Array of image objects … … 131 215 require_once(ABSPATH . 'wp-admin/includes/file.php'); 132 216 require_once(ABSPATH . 'wp-admin/includes/image.php'); 133 217 134 218 $url_mapping = array(); 135 219 $featured_id = null; 136 220 137 221 // Download ONLY featured image immediately (first image) 138 222 if ($set_featured && !empty($images[0])) { 139 223 $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 148 240 if (!is_wp_error($featured_id)) { 149 // Set as featured image150 241 set_post_thumbnail($post_id, $featured_id); 151 152 // Update alt text 242 153 243 if (isset($featured_image['altText'])) { 154 244 update_post_meta($featured_id, '_wp_attachment_image_alt', sanitize_text_field($featured_image['altText'])); 155 245 } 156 157 // Store heading text 246 158 247 if (isset($featured_image['headingText'])) { 159 248 update_post_meta($featured_id, 'draftseo_heading_text', sanitize_text_field($featured_image['headingText'])); 160 249 } 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 166 253 $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. 172 260 $remaining_images = $set_featured ? array_slice($images, 1) : $images; 173 261 174 262 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 186 290 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' 191 295 ); 192 296 } 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 201 400 */ 202 401 public static function process_images_background($args) { … … 204 403 return; 205 404 } 206 405 207 406 $post_id = intval($args['post_id']); 208 $images = $args['images'];209 407 $images = $args['images']; 408 210 409 require_once(ABSPATH . 'wp-admin/includes/media.php'); 211 410 require_once(ABSPATH . 'wp-admin/includes/file.php'); 212 411 require_once(ABSPATH . 'wp-admin/includes/image.php'); 213 412 214 413 $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. 217 424 foreach ($images as $image) { 218 425 if (empty($image['url'])) { 219 426 continue; 220 427 } 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 230 443 if (is_wp_error($attachment_id)) { 231 444 continue; 232 445 } 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 248 447 $wp_url = wp_get_attachment_url($attachment_id); 249 $url_mapping[$ image['url']] = $wp_url;250 } 251 252 // Replace NebiusURLs with WordPress local URLs in content448 $url_mapping[$url] = $wp_url; 449 } 450 451 // Replace CDN URLs with WordPress local URLs in content 253 452 if (!empty($url_mapping)) { 254 453 $updated_content = self::replace_image_urls($post_content, $url_mapping); 255 256 454 wp_update_post(array( 257 'ID' => $post_id,455 'ID' => $post_id, 258 456 'post_content' => $updated_content 259 457 )); 260 458 } 261 459 } 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 263 685 /** 264 686 * Replace image URLs in content 265 687 * 266 688 * @param string $content Post content 267 * @param array $url_mapping Array of NebiusURL => WordPress URL mappings689 * @param array $url_mapping Array of CDN URL => WordPress URL mappings 268 690 * @return string Updated content 269 691 */ … … 272 694 return $content; 273 695 } 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 279 701 return $content; 280 702 } 281 703 282 704 /** 283 705 * Check if image already exists in media library (duplicate detection) 284 706 * 285 * @param string $image_url NebiusCDN URL707 * @param string $image_url CDN URL 286 708 * @return int|false Attachment ID if exists, false otherwise 287 709 */ 288 710 public static function find_existing_image($image_url) { 289 711 global $wpdb; 290 712 291 713 // Generate hash of URL for comparison 292 714 $url_hash = md5($image_url); 293 715 294 716 // Try to get from cache first 295 $cache_key = 'draftseo_img_' . $url_hash;717 $cache_key = 'draftseo_img_' . $url_hash; 296 718 $attachment_id = wp_cache_get($cache_key, 'draftseo_images'); 297 719 298 720 if (false === $attachment_id) { 299 721 // Not in cache, query database … … 301 723 $attachment_id = $wpdb->get_var( 302 724 $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 306 728 LIMIT 1", 307 729 $url_hash 308 730 ) 309 731 ); 310 732 311 733 // Store in cache (cache even if not found to avoid repeated queries) 312 734 wp_cache_set($cache_key, $attachment_id ? $attachment_id : 0, 'draftseo_images', 3600); 313 735 } 314 736 315 737 return $attachment_id ? intval($attachment_id) : false; 316 738 } 317 739 318 740 /** 319 741 * Delete WordPress media attachments by their original CDN source URLs … … 328 750 return; 329 751 } 330 752 331 753 global $wpdb; 332 754 333 755 foreach ($urls as $url) { 334 756 if (empty($url)) { 335 757 continue; 336 758 } 337 759 338 760 $url = esc_url_raw($url); 339 761 340 762 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 341 763 $attachment_id = $wpdb->get_var( 342 764 $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 346 768 LIMIT 1", 347 769 $url 348 770 ) 349 771 ); 350 772 351 773 if ($attachment_id) { 352 774 // Delete attachment and its files from disk 353 775 wp_delete_attachment(intval($attachment_id), true); 354 776 355 777 // Clear the URL hash cache entry 356 778 $cache_key = 'draftseo_img_' . md5($url); … … 359 781 } 360 782 } 361 783 362 784 /** 363 785 * Store image URL hash for duplicate detection 364 786 * 365 787 * @param int $attachment_id WordPress attachment ID 366 * @param string $image_url NebiusCDN URL788 * @param string $image_url CDN URL 367 789 */ 368 790 public static function store_image_hash($attachment_id, $image_url) { … … 373 795 } 374 796 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. 376 800 add_action('draftseo_process_images_background', array('DraftSEO_Image_Handler', 'process_images_background')); 801 add_action('draftseo_process_images_with_callback', array('DraftSEO_Image_Handler', 'process_images_with_callback')); -
draftseo-ai/trunk/includes/class-rest-api.php
r3491200 r3493820 445 445 } 446 446 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 447 464 // Handle images 448 465 $images_imported = 0; 466 $has_callback_url = isset($params['callbackUrl']) && !empty($params['callbackUrl']); 467 $set_featured = isset($params['options']['setFeaturedImage']) && $params['options']['setFeaturedImage']; 468 449 469 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. 451 497 $result = DraftSEO_Image_Handler::import_images($params['images'], $post_id, $set_featured); 452 498 … … 478 524 } 479 525 480 // Store custom metadata481 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 provided492 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 497 526 // Log publication 498 527 self::log_publication($post_id, $params['id'] ?? null, 'success'); -
draftseo-ai/trunk/readme.txt
r3491200 r3493820 5 5 Tested up to: 6.9 6 6 Requires PHP: 7.4 7 Stable tag: 1.1. 27 Stable tag: 1.1.3 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 13 13 == Description == 14 14 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.15 Publish 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. 16 16 17 17 = Key Features = 18 18 19 19 * **One-Click Publishing** - Publish AI-generated blogs from DraftSEO.AI to WordPress instantly 20 * **Automatic Image Import** - Images are automatically transferred from DraftSEO. aito your WordPress Media Library21 * **SEO Optimization** - Maintains all SEO metadata, keywords, and meta descriptions22 * **Category & Tag Management** - Sync WordPress categories and automatically create tags from keywords20 * **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 23 23 * **Multiple Publishing Options** - Save as draft, publish immediately, or schedule for later 24 * **C ontent Cleanup** - Automatic HTML cleanup and formatting for WordPress compatibility25 * **Secure API Connection** - Encrypted API key storage using WordPress native encryption26 * ** HMAC-SHA256 Webhook Security** - Industry-standard signature verification for deactivation and disconnect notifications24 * **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 27 27 28 28 = How It Works = 29 29 30 30 1. 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 account32 3. Automatically connect using OAuth (no manual API key needed)33 4. Toggle Word press Auto-publish ON when generating blogs or click "Publish to WordPress" on already generated blogs31 2. Click "Connect with DraftSEO.ai" in plugin settings while you are logged in to your DraftSEO.ai account 32 3. Connection completes automatically via OAuth — no API key needed 33 4. Toggle WordPress Auto-publish ON when generating blogs, or click "Publish to WordPress" on any previously generated blog 34 34 35 35 = Image Import = 36 36 37 The plugin intelligently handles image import based on blog size: 38 39 * **1 -5 images**: Direct import (fast, 10-20seconds)40 * **6+ images**: Hybrid approach - featured image imported immediately, remaining images processed in background via WordPress Cron41 42 All images are downloaded from DraftSEO.ai directly to your WordPress Media Library, ensuring full ownership and no external dependencies.37 All 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. 43 43 44 44 … … 107 107 == Changelog == 108 108 109 = 1.1.3 = 110 111 Reliability 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 109 121 = 1.1.2 = 110 122 … … 113 125 = 1.1.0 = 114 126 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. 127 Automatic 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. 116 130 117 131 = 1.0.5 = … … 137 151 = 1.0.2 = 138 152 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 153 Citation 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 148 161 149 162 = 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 164 Hotfix 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 152 168 153 169 154 170 = 1.0.0 = 155 171 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 wire161 * Replay protection — Webhook requests include a Unix timestamp in `X-DraftSEO-Timestamp` header; requests older than 5 minutes are rejected162 * Timing-safe comparisons — All API key and signature comparisons use `hash_equals()` (PHP) and `crypto.timingSafeEqual` (Node.js) to prevent timing-based side-channel attacks163 * 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 handling165 * 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` endpoints170 * 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 integration172 * `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 runs174 * Remote disconnect endpoint — `/remote-disconnect` properly clears stored API key and connection settings when triggered from DraftSEO.AI platform175 * 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 sync176 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 silently180 * Sync endpoint timeout & abort — Added configurable timeout with AbortController to prevent hanging sync requests181 * 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 reliability183 * 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 cases185 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 calls190 * Optimized cache invalidation — Streamlined cache invalidation strategy; added health check invalidation after sync for immediate UI updates191 * 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)172 Major 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 192 208 193 209 **Usability** 194 210 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 220 212 221 213 = 0.2.0 = 222 * Initial beta release 214 215 Initial beta release. 216 223 217 * One-click blog publishing from DraftSEO.AI 224 218 * Automatic image import from DraftSEO.ai … … 235 229 == Upgrade Notice == 236 230 231 = 1.1.3 = 232 Reliability 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 237 234 = 1.1.2 = 238 235 Security update. Fixes API authentication failures on certain WordPress configurations that prevented DraftSEO.AI from syncing disconnect and deactivation events with your site. 239 236 240 237 = 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.238 When 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. 242 239 243 240 = 1.0.5 = 244 You tube Player styles for blogs.241 YouTube videos now display as embedded players on published WordPress posts. 245 242 246 243 = 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.244 Fixes headings appearing as plain text after images in published posts. Also removes unwanted image captions that were displaying as visible text below every image. 248 245 249 246 = 1.0.3 = -
draftseo-ai/trunk/uninstall.php
r3423447 r3493820 3 3 * Plugin Uninstall Handler 4 4 * 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. 6 15 * 7 16 * @package DraftSEO_Publisher 8 * @since 1. 0.017 * @since 1.1.3 9 18 */ 10 19 11 20 // Exit if accessed directly or not uninstalling 12 if ( !defined('WP_UNINSTALL_PLUGIN')) {21 if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { 13 22 exit; 14 23 } 15 24 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). 27 require_once plugin_dir_path( __FILE__ ) . 'includes/class-settings.php'; 28 16 29 /** 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 */ 37 function 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. 18 73 */ 19 74 function draftseo_publisher_uninstall() { 20 75 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. 28 87 $table_name = $wpdb->prefix . 'draftseo_logs'; 29 88 // 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 plugin89 $wpdb->query( 'DROP TABLE IF EXISTS ' . esc_sql( $table_name ) ); 90 91 // 4. Delete all post meta created by the plugin. 33 92 // 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' ); 40 100 } 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. 53 110 } 54 111 55 // Run uninstall 112 // Run uninstall. 56 113 draftseo_publisher_uninstall();
Note: See TracChangeset
for help on using the changeset viewer.