300, 'display' => __( 'Every 5 Minutes', 'ai-content-writer' ), ); $schedules['aicw_10_minutes'] = array( 'interval' => 600, 'display' => __( 'Every 10 Minutes', 'ai-content-writer' ), ); $schedules['aicw_15_minutes'] = array( 'interval' => 900, 'display' => __( 'Every 15 Minutes', 'ai-content-writer' ), ); $schedules['aicw_30_minutes'] = array( 'interval' => 1800, 'display' => __( 'Every 30 Minutes', 'ai-content-writer' ), ); return $schedules; } /** * Schedule global events that are not per-campaign. * * @since 2.2.5 * @return void */ public static function schedule_global_events(): void { if ( ! wp_next_scheduled( 'aicw_cleanup_logs' ) ) { wp_schedule_event( time(), 'daily', 'aicw_cleanup_logs' ); } } /** * Schedule the next process event for a campaign using its own interval. * Multiple events can be scheduled for multiple campaigns. * * @param int $campaign_id * @param string $frequency Campaign frequency. * @param int $delay Override delay in seconds. Defaults to campaign interval. * * @since 2.2.5 * @return void */ public static function schedule_campaign( int $campaign_id, string $frequency, int $delay = 0 ): void { $map = array( 'hourly' => 3600, 'twicedaily' => 43200, 'daily' => 86400, 'weekly' => 604800, 'monthly' => 2592000, 'aicw_5_minutes' => 300, 'aicw_10_minutes' => 600, 'aicw_15_minutes' => 900, 'aicw_30_minutes' => 1800, ); if ( 0 === $delay && isset( $map[ $frequency ] ) ) { $delay = $map[ $frequency ]; } // Schedule campaign-based single event. wp_schedule_single_event( time() + $delay, 'aicw_process_campaign', array( $campaign_id ) ); } /** * Main cron callback. Processes the current pipeline step for a given campaign. * Schedules the next event based on a step result. * * @param int $campaign_id * * @since 2.2.5 * @return void */ public static function process_campaign( int $campaign_id ): void { $campaign = aicw_get_campaign( $campaign_id ); if ( ! $campaign ) { return; } if ( 'publish' !== $campaign->post_status ) { return; } $frequency = get_post_meta( $campaign_id, '_aicw_campaign_frequency', true ); if ( ! $frequency ) { $frequency = 'hourly'; } // Check whether the campaign needs any updates. $needs_update = aicw_campaign_needs_update( $campaign_id ); if ( array_key_exists( 'needs_update', $needs_update ) && $needs_update['needs_update'] ) { wp_schedule_single_event(time() + self::STEP_DELAY, 'aicw_generate_titles', array( $campaign_id ) ); } // Check if the campaign needs generating content. if ( array_key_exists( 'pending_posts', $needs_update ) && is_array( $needs_update['pending_posts'] ) && ! empty( $needs_update['pending_posts'] ) ) { foreach ( $needs_update['pending_posts'] as $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { continue; } $post_needs_update = aicw_temp_post_needs_update( $post, $campaign_id ); // A temp post without a title can never progress (content and thumbnail // generation both require it) — delete it so the campaign generates a // fresh one instead of sitting in "pending" forever. if ( array_key_exists( 'title', $post_needs_update ) && $post_needs_update['title'] ) { ai_content_writer()->create_log( sprintf( /* translators: %d: Post ID. */ esc_html__( 'Deleted temporary post ID %d because it has no title and cannot be processed.', 'ai-content-writer' ), esc_html( $post_id ) ), 'warning', $campaign_id ); wp_delete_post( $post_id, true ); break; } if ( array_key_exists( 'content', $post_needs_update ) && $post_needs_update['content'] ) { self::generate_content( $post_id, $campaign_id ); } if ( array_key_exists( 'thumbnail', $post_needs_update ) && $post_needs_update['thumbnail'] ) { wp_schedule_single_event(time() + self::STEP_DELAY, 'aicw_generate_thumb_img', array( $post_id ) ); } // If no content or thumbnail needed but still pending, try to publish. if ( ! array_key_exists( 'content', $post_needs_update ) && ! array_key_exists( 'thumbnail', $post_needs_update ) ) { self::publish_posts( $post_id, $campaign_id ); } break; // Process one post at a time to avoid overlapping steps. Next cron will pick the next post. } } // Check if the campaign needs update or has any pending posts then schedule processing this campaign again. if ( array_key_exists( 'needs_update', $needs_update ) && $needs_update['needs_update'] || array_key_exists( 'pending_posts', $needs_update ) && $needs_update['pending_posts'] ) { self::schedule_campaign( $campaign_id, $frequency ); } else if ( array_key_exists( 'error', $needs_update ) && $needs_update['error'] ) { ai_content_writer()->create_log( sprintf( /* translators: 1: The campaign ID, 2: The Error text */ esc_html__( 'The following error occurred while processing campaign ID %1$d: %2$s', 'ai-content-writer' ), esc_html( $campaign_id ), esc_html( $needs_update['error'] ) ), 'error', $campaign_id ); } } /** * Generate titles for a single campaign. * * @param int $campaign_id * * @since 2.2.5 * @return bool True if titles were generated, false if nothing to do. */ public static function generate_titles( int $campaign_id ): bool { $campaign = aicw_get_campaign( $campaign_id ); if ( ! $campaign ) { return false; } $keywords = $campaign->post_content; $needs_update = aicw_campaign_needs_update( $campaign_id ); if ( array_key_exists( 'needs_update', $needs_update ) && ! $needs_update['needs_update'] ) { return false; } $type = get_post_meta( $campaign_id, '_aicw_campaign_type', true ); $posts_needed = isset( $needs_update['posts_needed'] ) ? absint( $needs_update['posts_needed'] ) : 0; if ( 'articles' === $type ) { Articles::generate_titles( $campaign_id, $keywords, $posts_needed ); return true; } if ( 'rss-feed' === $type ) { RSSFeed::generate_titles( $campaign_id, $posts_needed ); return true; } // Legacy AI model path. $type = empty( $type ) ? get_option( 'aicw_api_model' ) : $type; $titles = aicw_generate_titles( $type, $keywords, $posts_needed, array( 'language' => aicw_get_campaign_language( $campaign_id ) ) ); $counter = 0; foreach ( $titles as $title ) { if ( empty( $title ) ) { continue; } if ( ! preg_match( '/^(\d+\.\s|- )/', $title ) ) { continue; } $title = preg_replace( '/^(\d+\.\s*|- )/', '', $title ); $title = trim( $title, '"' ); if ( aicw_get_post_by_title( $title ) ) { continue; } $post_id = wp_insert_post( array( 'post_type' => 'aicw_post', 'post_title' => $title, 'post_content' => '', 'post_status' => 'pending', 'post_author' => get_post_field( 'post_author', $campaign_id ), ) ); if ( is_wp_error( $post_id ) ) { continue; } update_post_meta( $post_id, '_aicw_campaign_id', $campaign_id ); ++$counter; if ( array_key_exists( 'posts_needed', $needs_update ) && $counter >= $needs_update['posts_needed'] ) { ai_content_writer()->create_log( sprintf( /* translators: 1: Number of posts. 2: Campaign ID. 3: Campaign title. */ esc_html__( 'Created %1$d posts for campaign ID %2$d with title "%3$s".', 'ai-content-writer' ), esc_html( $counter ), esc_html( $campaign_id ), get_the_title( $campaign_id ) ), 'success', $campaign_id ); break; } } aicw_update_last_run_time( $campaign_id ); return $counter > 0; } /** * Generate content for one pending post belonging to a campaign. * * @param int $post_id Post ID. * @param int $campaign_id Campaign ID. * * @since 2.2.5 * @return void */ private static function generate_content( int $post_id, int $campaign_id ): void { $keywords = get_post_field( 'post_content', $campaign_id ); $title = get_the_title( $post_id ); $campaign_type = get_post_meta( $campaign_id, '_aicw_campaign_type', true ); if ( empty( $title ) || empty( $campaign_type ) ) { return; } if ( 'articles' === $campaign_type ) { $is_updated = Articles::generate_content( $post_id, $title, $keywords ); } elseif ( 'rss-feed' === $campaign_type ) { $is_updated = RSSFeed::generate_content( $post_id, $title, $keywords ); } else { $content = aicw_generate_content( $post_id, $title, $keywords ); $updated = wp_update_post( array( 'ID' => $post_id, 'post_content' => wp_kses_post( $content ), ) ); $is_updated = ! is_wp_error( $updated ); } if ( ! $is_updated ) { $attempts = absint( get_post_meta( $post_id, '_aicw_generate_content_attempts', true ) ); ++$attempts; if ( $attempts >= 2 ) { ai_content_writer()->create_log( sprintf( /* translators: 1: Post ID. 2: Post title. 3: Attempts. */ esc_html__( 'Failed to generate content for post ID %1$d with title "%2$s" after %3$d attempts. Deleting the post.', 'ai-content-writer' ), esc_html( $post_id ), get_the_title( $post_id ), esc_html( $attempts ) ), 'error', $campaign_id ); wp_delete_post( $post_id, true ); } else { update_post_meta( $post_id, '_aicw_generate_content_attempts', $attempts ); } return; } ai_content_writer()->create_log( sprintf( /* translators: 1: Post ID. 2: Post title. */ esc_html__( 'Generated content for post ID %1$d with title "%2$s".', 'ai-content-writer' ), esc_html( $post_id ), get_the_title( $post_id ) ), 'success', $campaign_id ); } /** * Generate a featured image for one pending post belonging to a campaign. * * @param int $post_id Post ID. * * @since 2.2.5 * @return void */ public static function generate_thumbnail( int $post_id ): void { $title = get_the_title( $post_id ); if ( empty( $title ) ) { return; } $images = ai_content_writer()->generate_images( $title ); if ( is_wp_error( $images ) || empty( $images ) ) { return; } $thumbnail = $images[ array_rand( $images ) ]; if ( ! isset( $thumbnail['src']['original'] ) ) { return; } if ( ! function_exists( 'media_sideload_image' ) ) { require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; } $campaign_id = get_post_meta( $post_id, '_aicw_campaign_id', true ); $media_id = media_sideload_image( $thumbnail['src']['original'], $post_id, $title, 'id' ); if ( is_wp_error( $media_id ) ) { ai_content_writer()->create_log( sprintf( /* translators: 1: Post ID. 2: Post title. 3: Error message. */ esc_html__( 'Failed to generate thumbnail for post ID %1$d with title "%2$s". Error: %3$s', 'ai-content-writer' ), esc_html( $post_id ), get_the_title( $post_id ), esc_html( $media_id->get_error_message() ) ), 'error', $campaign_id ); return; } set_post_thumbnail( $post_id, $media_id ); // Image SEO & Pexels attribution: alt text, caption credit, and source URL. $alt_text = ! empty( $thumbnail['alt'] ) ? $thumbnail['alt'] : $title; update_post_meta( $media_id, '_wp_attachment_image_alt', sanitize_text_field( $alt_text ) ); if ( ! empty( $thumbnail['photographer'] ) ) { wp_update_post( array( 'ID' => $media_id, 'post_excerpt' => sprintf( /* translators: %s: Photographer name. */ esc_html__( 'Photo by %s on Pexels', 'ai-content-writer' ), sanitize_text_field( $thumbnail['photographer'] ) ), ) ); } if ( ! empty( $thumbnail['url'] ) ) { update_post_meta( $media_id, '_aicw_image_source_url', esc_url_raw( $thumbnail['url'] ) ); } ai_content_writer()->create_log( sprintf( /* translators: 1: Post ID. 2: Post title. */ esc_html__( 'Generated thumbnail for post ID %1$d with title "%2$s".', 'ai-content-writer' ), esc_html( $post_id ), get_the_title( $post_id ) ), 'success', $campaign_id ); } /** * Publish one completed post belonging to a campaign. * * Public so the review-queue "Approve" action can promote a post on demand. * * @param int $post_id Post ID. * @param int $campaign_id Campaign ID. * * @since 2.2.5 * @return void */ public static function publish_posts( int $post_id, int $campaign_id ): void { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return; } // aicw_temp_post_needs_update returns empty when all steps are complete. $needs_update = aicw_temp_post_needs_update( $post, $campaign_id ); if ( ! empty( $needs_update ) ) { return; } $content = $post->post_content; $block_keywords = get_post_meta( $campaign_id, '_aicw_block_keywords', true ); $keywords_array = array_filter( array_map( 'trim', explode( ',', $block_keywords ) ) ); $replacements = array_fill_keys( $keywords_array, '' ); /** @see aicw_before_content_filtering */ $replacements = apply_filters( 'aicw_before_content_filtering', $replacements, $campaign_id, $post_id ); if ( ! empty( $replacements ) && is_array( $replacements ) ) { $content = str_replace( array_keys( $replacements ), array_values( $replacements ), $content ); $content = preg_replace( '/\s+/', ' ', $content ); $content = trim( $content ); } $insert_content_position = get_post_meta( $campaign_id, '_aicw_insert_content_position', true ); $insert_content = get_post_meta( $campaign_id, '_aicw_insert_content', true ); if ( ! empty( $insert_content ) && 'none' !== $insert_content_position ) { $content = 'before' === $insert_content_position ? $insert_content . ' ' . $content : $content . ' ' . $insert_content; } $post_type = get_post_meta( $campaign_id, '_aicw_post_type', true ) ?: 'post'; $post_status = get_post_meta( $campaign_id, '_aicw_completed_post_status', true ) ?: 'publish'; $postarr = array( 'ID' => $post_id, 'post_type' => $post_type, 'post_status' => $post_status, 'post_content' => wp_kses_post( $content ), ); // Assign the configured author, if any. $author_id = absint( get_post_meta( $campaign_id, '_aicw_post_author', true ) ); if ( $author_id && get_userdata( $author_id ) ) { $postarr['post_author'] = $author_id; } // SEO slug from the structured AI response, when available. $seo = get_post_meta( $post_id, '_aicw_seo', true ); if ( ! empty( $seo['slug'] ) ) { $postarr['post_name'] = $seo['slug']; } /** * Filter the post array right before a generated post is promoted. * * Extensions can adjust the final status (e.g. hold low-quality posts * as drafts) or any other field. * * @param array $postarr The post array passed to wp_update_post(). * @param int $post_id The post ID. * @param int $campaign_id The campaign ID. * * @since 2.4.0 */ $postarr = apply_filters( 'aicw_publish_postarr', $postarr, $post_id, $campaign_id ); wp_update_post( $postarr ); // Assign categories: campaign-configured terms win; otherwise map the // AI-suggested names onto existing categories only (never create). if ( is_object_in_taxonomy( $post_type, 'category' ) ) { $categories = get_post_meta( $campaign_id, '_aicw_post_categories', true ); if ( ! empty( $categories ) && is_array( $categories ) ) { wp_set_post_terms( $post_id, array_map( 'absint', $categories ), 'category' ); } else { $suggested = get_post_meta( $post_id, '_aicw_suggested_categories', true ); $term_ids = array(); if ( ! empty( $suggested ) && is_array( $suggested ) ) { foreach ( $suggested as $name ) { $term = get_term_by( 'name', $name, 'category' ); if ( $term instanceof \WP_Term ) { $term_ids[] = (int) $term->term_id; } } } if ( ! empty( $term_ids ) ) { wp_set_post_terms( $post_id, $term_ids, 'category' ); } } } // Assign tags: campaign-configured tags merged with the AI suggestions. if ( is_object_in_taxonomy( $post_type, 'post_tag' ) ) { $tags = get_post_meta( $campaign_id, '_aicw_post_tags', true ); $tag_names = array_filter( array_map( 'trim', explode( ',', (string) $tags ) ) ); $suggested = get_post_meta( $post_id, '_aicw_suggested_tags', true ); if ( ! empty( $suggested ) && is_array( $suggested ) ) { $tag_names = array_unique( array_merge( $tag_names, $suggested ) ); } if ( ! empty( $tag_names ) ) { wp_set_post_terms( $post_id, $tag_names, 'post_tag' ); } } // Write the SEO fields into the active SEO plugin (Yoast / Rank Math / // AIOSEO / SEOPress), unless the campaign disabled it. if ( 'no' !== get_post_meta( $campaign_id, '_aicw_seo_enabled', true ) ) { aicw_apply_seo_meta( $post_id ); } /** * Fires after a generated post has been promoted to its final type/status. * * @param int $post_id The post ID. * @param int $campaign_id The campaign ID. * * @since 2.4.0 */ do_action( 'aicw_post_published', $post_id, $campaign_id ); ai_content_writer()->create_log( sprintf( /* translators: 1: Post ID. 2: Post title. */ esc_html__( 'Published post ID %1$d with title "%2$s".', 'ai-content-writer' ), esc_html( $post_id ), get_the_title( $post_id ) ), 'success', $campaign_id ); } /** * Cleanup logs older than the retention period. * * @since 2.0.6 * @return void */ public static function cleanup_logs(): void { $retention_period = absint( get_option( 'aicw_log_retention_period', 30 ) ); if ( $retention_period <= 0 ) { return; } $cutoff_date = gmdate( 'Y-m-d', strtotime( "-{$retention_period} days" ) ); $logs = get_posts( array( 'post_type' => 'aicw_log', 'posts_per_page' => -1, 'date_query' => array( array( 'before' => $cutoff_date, 'inclusive' => true, ), ), 'fields' => 'ids', 'orderby' => 'date', 'order' => 'ASC', ) ); if ( ! empty( $logs ) ) { foreach ( $logs as $log_id ) { wp_delete_post( $log_id, true ); } ai_content_writer()->create_log( sprintf( /* translators: 1: Count. 2: Days. */ esc_html__( 'Deleted %1$d logs older than %2$d days.', 'ai-content-writer' ), count( $logs ), $retention_period ) ); } } }