enabled = ! empty( $settings['fallback_ai'] ); if ( $this->enabled ) { add_action( 'save_post', array( $this, 'schedule_fallback_generation' ), 99, 3 ); add_action( 'metaforge_async_fallback_generation', array( $this, 'process_fallback_generation' ) ); } } public function schedule_fallback_generation( $post_id, $post, $update ) { if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return; } // Schedule async event to avoid blocking UI during post save if ( ! wp_next_scheduled( 'metaforge_async_fallback_generation', array( $post_id ) ) ) { wp_schedule_single_event( time(), 'metaforge_async_fallback_generation', array( $post_id ) ); } } public function process_fallback_generation( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { return; } $client = new MetaForge_APIClient(); if ( ! $client->is_ready() ) { return; } $fm = new MetaForge_FieldManager(); $groups = $fm->get_groups_by_post_type( $post->post_type, $post_id ); if ( empty( $groups ) ) { return; } $fields_to_fill = array(); $existing_data = array(); $supported_types = array( 'text', 'textarea', 'wysiwyg', 'image', 'color_picker', 'true_false', 'select', 'checkbox', 'number' ); foreach ( $groups as $group ) { foreach ( $group['fields'] as $field ) { if ( in_array( $field['type'], $supported_types ) ) { $val = get_post_meta( $post_id, $field['name'], true ); if ( empty( $val ) ) { $fields_to_fill[] = $field; } else { $existing_data[ $field['name'] ] = $val; } } } } if ( empty( $fields_to_fill ) ) { return; } $system_prompt = "You are an AI content generator for WordPress metadata. Generate appropriate fallback content for the explicitly requested empty fields, based on the Post Title, Post Content, and other known meta values. Return a JSON object containing key-value pairs matching the field names. Requirements by field type: 1. text / textarea / wysiwyg: Return a string representing relevant content. 2. image: Return a keyword or description of a relevant image (e.g. \"modern office building\", \"fresh salad\", etc.). 3. color_picker: Return a 6-digit hex color code matching the post topic (e.g. \"#4f46e5\"). 4. true_false: Return 1 or 0 (as an integer). 5. select: Return one of the keys from the provided 'available_choices' (keys are the part before the colon in each choice line, or the value itself if no colon). 6. checkbox: Return an array of keys from the provided 'available_choices'. 7. number: Return a realistic numeric value (integer or float). Example output: { \"field_name_text\": \"Generated content\", \"field_name_image\": \"mountain landscape\", \"field_name_color\": \"#3b82f6\", \"field_name_bool\": 1, \"field_name_select\": \"key1\", \"field_name_checkbox\": [\"key1\", \"key2\"], \"field_name_number\": 42 }"; $context = array( 'post_title' => $post->post_title, 'post_content' => wp_strip_all_tags( $post->post_content ), 'existing_meta' => $existing_data, 'empty_fields_to_fill' => array_map( function ( $f ) { $data = array( 'name' => $f['name'], 'label' => $f['label'], 'type' => $f['type'], ); if ( in_array( $f['type'], array( 'select', 'checkbox' ) ) && ! empty( $f['options_raw'] ) ) { $data['available_choices'] = $f['options_raw']; } return $data; }, $fields_to_fill ), ); $user_prompt = "Context Data:\n" . wp_json_encode( $context ); $response = $client->generate( $system_prompt, $user_prompt, true ); if ( is_wp_error( $response ) || empty( $response ) ) { return; } $response = trim( $response ); if ( strpos( $response, '```json' ) === 0 ) { $response = substr( $response, 7 ); $response = substr( $response, 0, -3 ); $response = trim( $response ); } elseif ( strpos( $response, '```' ) === 0 ) { $response = substr( $response, 3 ); $response = substr( $response, 0, -3 ); $response = trim( $response ); } $decoded = json_decode( $response, true ); if ( $decoded && is_array( $decoded ) ) { $decoded = map_deep( $decoded, 'sanitize_textarea_field' ); foreach ( $decoded as $key => $val ) { // Find matching field definition $field_def = null; foreach ( $fields_to_fill as $f ) { if ( $f['name'] === $key ) { $field_def = $f; break; } } if ( ! $field_def ) { continue; } $current = get_post_meta( $post_id, $key, true ); if ( empty( $current ) ) { $save_val = null; switch ( $field_def['type'] ) { case 'wysiwyg': $save_val = wp_kses_post( $val ); break; case 'true_false': $save_val = ! empty( $val ) ? 1 : 0; break; case 'number': $save_val = is_numeric( $val ) ? (float) $val : intval( $val ); break; case 'checkbox': if ( is_array( $val ) ) { $save_val = array_map( 'sanitize_text_field', $val ); } elseif ( is_string( $val ) ) { $save_val = array( sanitize_text_field( $val ) ); } else { $save_val = array(); } break; case 'select': case 'text': case 'textarea': case 'color_picker': $save_val = sanitize_text_field( $val ); break; case 'image': $keyword = sanitize_text_field( $val ); $save_val = $this->download_placeholder_image( $keyword, $post_id ); break; } if ( $save_val !== null ) { update_post_meta( $post_id, sanitize_key( $key ), $save_val ); } } } } } private function download_placeholder_image( $keyword, $post_id ) { global $wpdb; $clean_keyword = sanitize_text_field( $keyword ); // 1. Query the media library first to see if an image with similar title/description exists to reuse // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Media library attachment query by title/content keyword. $attachment = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE post_type = 'attachment' AND (post_title LIKE %s OR post_content LIKE %s) LIMIT 1", '%' . $wpdb->esc_like( $clean_keyword ) . '%', '%' . $wpdb->esc_like( $clean_keyword ) . '%' ) ); if ( ! empty( $attachment ) ) { return intval( $attachment[0] ); } // 2. Query any existing image attachment in the media library $existing = get_posts( array( 'post_type' => 'attachment', 'post_mime_type' => 'image', 'posts_per_page' => 1, 'post_status' => 'inherit', ) ); if ( ! empty( $existing ) ) { return intval( $existing[0]->ID ); } // 3. Generate a local self-contained SVG placeholder without calling any external service return $this->generate_local_placeholder_image( $clean_keyword, $post_id ); } /** * Generate a self-contained local SVG placeholder image in the uploads directory. * Operates 100% offline without connecting to external services. */ private function generate_local_placeholder_image( $keyword, $post_id ) { $upload_dir = wp_upload_dir(); if ( empty( $upload_dir['path'] ) || ! wp_is_writable( $upload_dir['path'] ) ) { return null; } $clean_name = sanitize_file_name( strtolower( $keyword ? $keyword : 'placeholder' ) ); $filename = 'mfe-placeholder-' . $clean_name . '.svg'; $filepath = trailingslashit( $upload_dir['path'] ) . $filename; $fileurl = trailingslashit( $upload_dir['url'] ) . $filename; $title = ! empty( $keyword ) ? ucwords( str_replace( array( '-', '_' ), ' ', $keyword ) ) : 'Placeholder Image'; if ( ! file_exists( $filepath ) ) { $svg = '' . '' . '' . '' . '' . esc_html( $title ) . '' . '1200 × 800 • MetaForge Placeholder' . ''; global $wp_filesystem; if ( empty( $wp_filesystem ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; WP_Filesystem(); } if ( $wp_filesystem ) { $wp_filesystem->put_contents( $filepath, $svg, FS_CHMOD_FILE ); } } if ( ! file_exists( $filepath ) ) { return null; } $attachment = array( 'guid' => $fileurl, 'post_mime_type' => 'image/svg+xml', 'post_title' => 'AI Placeholder: ' . $title, 'post_content' => 'AI Placeholder: ' . $keyword, 'post_status' => 'inherit', ); $attach_id = wp_insert_attachment( $attachment, $filepath, $post_id ); return ( ! is_wp_error( $attach_id ) && $attach_id ) ? intval( $attach_id ) : null; } }