namespace, '/' . $this->rest_base, array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'generate_schema' ), 'permission_callback' => array( $this, 'generate_permissions_check' ), 'args' => array( 'post_id' => array( 'description' => __( 'The WordPress post ID to generate schema for.', 'ai-schema-gen' ), 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ), 'schema_type' => array( 'description' => __( 'Optional specific schema type to generate.', 'ai-schema-gen' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), ), ) ); // POST /generate-archive - Generate CollectionPage schema for a taxonomy // term archive (category/tag page). Deterministic, no AI call — see // generate_for_term(). register_rest_route( $this->namespace, '/generate-archive', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'generate_archive_schema' ), 'permission_callback' => array( $this, 'generate_permissions_check' ), 'args' => array( 'term_id' => array( 'description' => __( 'The WordPress term ID to generate a CollectionPage schema for.', 'ai-schema-gen' ), 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ), 'taxonomy' => array( 'description' => __( 'The taxonomy the term belongs to (e.g. category, post_tag).', 'ai-schema-gen' ), 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key', ), ), ), ) ); // POST /analyze - Analyse content only (no generation). register_rest_route( $this->namespace, '/analyze', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'analyze_content' ), 'permission_callback' => array( $this, 'generate_permissions_check' ), 'args' => array( 'post_id' => array( 'description' => __( 'The WordPress post ID to analyse.', 'ai-schema-gen' ), 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ), ), ), ) ); } /** * Get the SaaS API key from plugin settings. * * @return string The API key, or empty string if not configured. */ private function get_api_key() { $settings = get_option( 'schema_ai_settings', array() ); return ! empty( $settings['api_key'] ) ? $settings['api_key'] : ''; } /** * Get this site's SaaS-side site ID, set after the first sync * (see Schema_AI_Sync::SITE_ID_OPTION). Sending it with every /generate * request lets the backend look up the site's configured business type * as AI grounding context (see backend's generate.ts) and link the * generated schema back to this site's dashboard records -- without it, * the AI classifies each page from that page's own text alone, with no * awareness of what kind of business this actually is. * * @return int Site ID, or 0 if not yet synced. */ private function get_site_id() { return (int) get_option( 'schema_ai_saas_site_id', 0 ); } /** * Count FAQ-shaped question+answer pairs in content — a question line * immediately followed by a non-question line (the answer). * * Mirrors countFaqPairs() in the backend's classifier.ts. Kept in sync * deliberately: this plugin-side count independently forces 'FAQPage' * into the generate request (see the two call sites below), which the * backend then unions into its own result regardless of what its own * classifier decided — so a stale heuristic here silently defeats a fix * made only on the backend side. A bare question-line count (the * previous approach here) matched marketing CTA headings phrased as * questions ("Ready to Build?") with no real FAQ behind them — confirmed * live: a /services/ page with two such CTAs kept getting a fabricated * FAQPage schema even after the backend's own detection was fixed, * because this endpoint was still forcing it back in on every * "Generate Schema" click. * * @param string $content Prepared page content. * @return int Number of question-then-answer pairs found. */ private function count_faq_pairs( $content ) { $lines = array_values( array_filter( array_map( 'trim', explode( "\n", $content ) ), 'strlen' ) ); $question_line = '/^[A-Z][^\n?!]{10,}\?$/'; $pairs = 0; for ( $i = 0; $i < count( $lines ) - 1; $i++ ) { if ( preg_match( $question_line, $lines[ $i ] ) && ! preg_match( $question_line, $lines[ $i + 1 ] ) ) { $pairs++; } } return $pairs; } /** * Whether a page title identifies a legal/policy document (Terms of * Service, Privacy Policy, Disclaimer, ...). * * Mirrors isLegalPolicyTitle() in the backend's classifier.ts — these * pages are typically structured as numbered clauses ("1. Acceptance of * Terms", "2. Limitation of Liability", ...), which reads identically to * numbered HowTo steps to a keyword/pattern-based check. * * @param string $title Post title. * @return bool */ private function is_legal_policy_title( $title ) { return (bool) preg_match( '/\b(terms( (of|and) (service|use|conditions?))?|privacy policy|cookie policy|disclaimer|refund policy|return policy|shipping policy|legal notice|acceptable use policy|gdpr)\b/i', $title ); } /** * Prepare post content for the SaaS backend. * * Includes post data and verified business information from settings * so the AI uses real data instead of generating placeholders. * * @param int $post_id The WordPress post ID. * @return string Formatted content string. */ private function prepare_content( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { return ''; } $content = do_shortcode( do_blocks( $post->post_content ) ); // Preserve block-level structure before stripping tags so the AI can // detect FAQs, numbered steps, and headings as separate sections. $content = preg_replace( '/<\/(p|h[1-6]|li|div|blockquote|tr)>/i', "\n", $content ); $content = preg_replace( '//i', "\n", $content ); $content = wp_strip_all_tags( $content ); // Normalise spaces/tabs but keep paragraph breaks. $content = preg_replace( '/[ \t]+/', ' ', $content ); $content = preg_replace( '/\n[ \t]+/', "\n", $content ); $content = preg_replace( '/\n{3,}/', "\n\n", trim( $content ) ); $settings = get_option( 'schema_ai_settings', array() ); $parts = array(); // Business info from settings — these are REAL values, use them exactly. $parts[] = '=== VERIFIED BUSINESS INFO (use exactly as provided, do NOT invent or modify) ==='; $org_name = ! empty( $settings['default_org_name'] ) ? $settings['default_org_name'] : get_bloginfo( 'name' ); $parts[] = 'Organization Name: ' . $org_name; if ( class_exists( 'Schema_AI_Business_Subtype' ) ) { $parts[] = 'Business Entity Type: ' . Schema_AI_Business_Subtype::get_type_from_settings() . ' (reference only — do NOT generate a business entity schema, it is injected from settings automatically)'; } $org_url = ! empty( $settings['default_org_url'] ) ? $settings['default_org_url'] : home_url( '/' ); $parts[] = 'Organization URL: ' . $org_url; // Logo — use the real one from settings or WordPress custom logo. $logo_url = ''; if ( ! empty( $settings['default_org_logo'] ) ) { $logo_url = $settings['default_org_logo']; } else { $custom_logo_id = get_theme_mod( 'custom_logo' ); if ( $custom_logo_id ) { $logo_src = wp_get_attachment_image_src( $custom_logo_id, 'full' ); if ( $logo_src ) { $logo_url = $logo_src[0]; } } } if ( ! empty( $logo_url ) ) { $parts[] = 'Organization Logo URL: ' . $logo_url; } else { $parts[] = 'Organization Logo URL: NONE (omit the image/logo field entirely if no real URL is available)'; } // Featured image of this specific post/page — use as "image" in schema if available. $featured_img = get_the_post_thumbnail_url( $post_id, 'large' ); if ( ! empty( $featured_img ) ) { $parts[] = 'Page Featured Image: ' . $featured_img; } // Phone number. if ( ! empty( $settings['default_org_phone'] ) ) { $parts[] = 'Telephone: ' . $settings['default_org_phone']; } else { $parts[] = 'Telephone: NONE (omit the telephone field if no real number is available)'; } // Contact email. if ( ! empty( $settings['default_org_email'] ) ) { $parts[] = 'Contact Email: ' . $settings['default_org_email']; } else { $parts[] = 'Contact Email: NONE (omit the email field if no real address is available)'; } // Address — only include if a street address is provided (physical location business). $has_street = ! empty( $settings['default_org_address'] ); if ( $has_street ) { $address_parts = array(); $address_parts[] = 'streetAddress: ' . $settings['default_org_address']; if ( ! empty( $settings['default_org_city'] ) ) { $address_parts[] = 'addressLocality: ' . $settings['default_org_city']; } if ( ! empty( $settings['default_org_state'] ) ) { $address_parts[] = 'addressRegion: ' . $settings['default_org_state']; } if ( ! empty( $settings['default_org_zip'] ) ) { $address_parts[] = 'postalCode: ' . $settings['default_org_zip']; } if ( ! empty( $settings['default_org_country'] ) ) { $address_parts[] = 'addressCountry: ' . $settings['default_org_country']; } $parts[] = 'Business Address: ' . implode( ', ', $address_parts ); $parts[] = 'Business Type: Physical location business (use "address" in schema)'; } else { $parts[] = 'Business Address: NONE — this is a service-area business with no public office.'; $parts[] = 'Business Type: Service-area business (do NOT include "address" in schema, use "areaServed" with @type "AdministrativeArea" instead. Derive the service area from the page content.)'; } $parts[] = '=== END VERIFIED BUSINESS INFO ==='; $parts[] = ''; // Post data. $parts[] = 'Page Title: ' . $post->post_title; $parts[] = 'Page URL: ' . get_permalink( $post_id ); $parts[] = 'Post Type: ' . $post->post_type; // Dates — send real WordPress dates in ISO 8601 format with timezone offset // so the AI uses exact values instead of guessing. Google requires full // datetime strings (e.g. 2026-01-15T14:30:00+00:00), not date-only strings. $date_published = get_post_datetime( $post_id, 'date', 'gmt' ); $date_modified = get_post_datetime( $post_id, 'modified', 'gmt' ); if ( $date_published instanceof DateTimeImmutable ) { $parts[] = 'Date Published (ISO 8601, use exactly as-is for datePublished ONLY on content types like Article/BlogPosting/WebPage — schema.org does not define datePublished/dateModified on a business entity type like LocalBusiness/Organization/GeneralContractor or any of its subtypes, so omit both fields entirely if that is the type you are generating): ' . $date_published->format( 'c' ); } if ( $date_modified instanceof DateTimeImmutable ) { $parts[] = 'Date Modified (ISO 8601, use exactly as-is for dateModified — same content-type-only restriction as Date Published above): ' . $date_modified->format( 'c' ); } $categories = get_the_terms( $post_id, 'category' ); if ( $categories && ! is_wp_error( $categories ) ) { $parts[] = 'Categories: ' . implode( ', ', wp_list_pluck( $categories, 'name' ) ); } $parts[] = ''; // Schema type constraints based on post type. $post_type = $post->post_type; if ( 'post' === $post_type ) { $parts[] = 'SCHEMA TYPE RULE: This is a blog post. Primary schema MUST be Article (or BlogPosting/NewsArticle). Only add FAQPage if the content contains clearly structured Q&A or FAQ sections. Only add HowTo if the content contains explicit numbered steps. Do NOT generate LocalBusiness, Organization, Service, or WebPage schemas for blog posts.'; } elseif ( 'page' === $post_type ) { $parts[] = 'SCHEMA TYPE RULE: This is a WordPress page. DO NOT generate LocalBusiness, Organization, or any business entity schema — the business entity block is built from verified settings and injected automatically. Instead generate ONLY content-relevant schemas based on what is actually on this page: Course or EducationalOccupationalProgram (training/course pages), Service (service description pages), Event (event pages), FAQPage (pages with Q&A sections), HowTo (pages with numbered steps), Article (informational content), or WebPage as a last resort. Never guess a business industry type.'; if ( $this->is_legal_policy_title( $post->post_title ) ) { $parts[] = 'LEGAL PAGE RULE: This page\'s title identifies it as a Terms of Service / Privacy Policy / legal-policy document. Its numbered clauses (e.g. "1. Acceptance of Terms") are NOT how-to steps — do NOT generate HowTo for this page under any circumstances, even though it contains a numbered list. Use WebPage unless another content-relevant type from the list above clearly applies.'; } } elseif ( 'product' === $post_type ) { $parts[] = 'SCHEMA TYPE RULE: This is a product page. Primary schema MUST be Product. Only add Review/AggregateRating if real review data is present in the content.'; } else { $parts[] = 'SCHEMA TYPE RULE: Determine schema types strictly from the content. Only generate schema types that are clearly supported by the actual page content. Do not infer or assume types not evidenced in the content.'; } $parts[] = ''; $parts[] = 'MULTI-SCHEMA RULE: If the content clearly supports more than one schema type (e.g. Article + FAQPage, or Course + FAQPage), return all of them as a JSON array. Otherwise return a single schema object.'; $parts[] = ''; $parts[] = 'IMPORTANT: For serviceArea, use @type "AdministrativeArea" (not "Thing"). NEVER include "review", "aggregateRating", or "starRating" in LocalBusiness or Organization schemas — Google considers reviews on the business\'s own website to be "self-serving" and will reject the rich result. Only include review/rating markup for Product, Recipe, Book, Course, or similar non-business schema types where the review is from a genuine user. If data is not available, omit the field.'; $parts[] = ''; $parts[] = 'PROPERTY RULE: HowTo, Article, BlogPosting, FAQPage, Service, and Course schemas MUST NOT include `address`, `telephone`, `serviceArea`, or `areaServed` properties — those belong in business entity schemas only and will cause validation errors if placed on other types.'; $parts[] = ''; $parts[] = 'AUTHOR RULE: For Article and BlogPosting schemas, always include `"author": {"@type": "Person", "name": "[author name from the content, or the organization name if no individual author is mentioned]"}`.'; $parts[] = ''; $parts[] = 'Page Content: ' . $content; return implode( "\n", $parts ); } /** * Generate schema markup via the SaaS backend. * * Sends the post content to the backend which handles classification, * AI generation, rate limiting, and usage tracking. The plugin saves * the returned schema locally. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object or error. */ public function generate_schema( $request ) { $post_id = absint( $request->get_param( 'post_id' ) ); $schema_type = sanitize_text_field( $request->get_param( 'schema_type' ) ); // Validate the post. $valid = $this->validate_post_id( $post_id ); if ( is_wp_error( $valid ) ) { return $valid; } // Get API key. $api_key = $this->get_api_key(); if ( empty( $api_key ) ) { return $this->error_response( 'schema_ai_no_api_key', __( 'AI Schema Gen API key is not configured. Please add it in AI Schema Gen settings.', 'ai-schema-gen' ), 400 ); } // Prepare content. $content = $this->prepare_content( $post_id ); if ( empty( $content ) ) { return $this->error_response( 'schema_ai_empty_content', __( 'Post content is empty or could not be read.', 'ai-schema-gen' ), 400 ); } // Detect secondary schema types from content using PHP — do not rely // on the AI to detect these, so they are guaranteed to be generated. $secondary_types = array(); // FAQPage: 3+ question-then-answer pairs — see count_faq_pairs(). if ( $this->count_faq_pairs( $content ) >= 3 ) { $secondary_types[] = 'FAQPage'; } // Build request body. $body = array( 'content' => $content, 'url' => get_permalink( $post_id ), ); $site_id = $this->get_site_id(); if ( $site_id ) { $body['siteId'] = $site_id; } if ( ! empty( $secondary_types ) ) { $body['secondaryTypes'] = $secondary_types; } if ( ! empty( $schema_type ) ) { $body['schemaType'] = $schema_type; } // Call the SaaS backend — it handles rate limiting, AI, and usage tracking. $response = wp_remote_post( SCHEMA_AI_API_BASE . '/generate', array( 'timeout' => 90, 'headers' => array( 'Content-Type' => 'application/json', 'X-API-Key' => $api_key, ), 'body' => wp_json_encode( $body ), ) ); if ( is_wp_error( $response ) ) { return $this->error_response( 'schema_ai_backend_error', sprintf( /* translators: %s: error message */ __( 'Could not connect to AI Schema Gen server: %s', 'ai-schema-gen' ), $response->get_error_message() ), 502 ); } $status_code = wp_remote_retrieve_response_code( $response ); $body_raw = wp_remote_retrieve_body( $response ); $data = json_decode( $body_raw, true ); if ( 200 !== $status_code || empty( $data['success'] ) ) { $error_msg = isset( $data['error'] ) ? $data['error'] : __( 'Schema generation failed.', 'ai-schema-gen' ); // Map backend HTTP status codes to specific error codes so the // JavaScript client can show the correct UI (upgrade notice, etc.). if ( 429 === $status_code ) { return new WP_Error( 'schema_ai_limit_reached', $error_msg, array( 'status' => 429 ) ); } if ( 403 === $status_code ) { return new WP_Error( 'schema_ai_suspended', $error_msg, array( 'status' => 403 ) ); } return $this->error_response( 'schema_ai_generation_failed', $error_msg, $status_code >= 400 ? $status_code : 500 ); } $result = isset( $data['data'] ) ? $data['data'] : array(); $this->maybe_sync_plan( $result ); // Support both a single schema object and an array of schemas from the backend. // First schema in the set clears all old schemas for this post (replace, not append). // Subsequent schemas in the same generation are appended. // // Backend returns schemas as: [ { schema: {...}, schemaType: "Article", ... }, ... ] // Extract the inner schema object from each item before saving. $schemas_to_save = array(); if ( ! empty( $result['schemas'] ) && is_array( $result['schemas'] ) ) { foreach ( $result['schemas'] as $item ) { if ( ! empty( $item['schema'] ) && is_array( $item['schema'] ) ) { $schemas_to_save[] = $item['schema']; } } } // Fallback to legacy single-schema key if schemas array is empty or absent. if ( empty( $schemas_to_save ) && ! empty( $result['schema'] ) ) { $schemas_to_save = array( $result['schema'] ); } // Option B: for WordPress 'page' post type, strip any AI-guessed business entity // schema and prepend one built entirely from plugin settings (correct @type // guaranteed) — but only on the homepage. Every other page gets a lightweight // @id reference instead of a full duplicate block (matches Google's own // guidance against repeating the full Organization/LocalBusiness block on // every page). if ( $this->should_inject_entity_schema( $post_id ) ) { $schemas_to_save = $this->strip_entity_schemas( $schemas_to_save ); if ( $this->is_homepage_post( $post_id ) ) { array_unshift( $schemas_to_save, $this->build_entity_schema( $post_id ) ); } elseif ( $this->homepage_has_entity_schema() ) { $schemas_to_save = $this->attach_entity_reference( $schemas_to_save ); } } // @id cross-reference check — retry once if stubs dangle, WebPage fallback on double-fail. if ( ! empty( $schemas_to_save ) && class_exists( 'Schema_AI_Schema_Validator' ) ) { $validator = new Schema_AI_Schema_Validator(); $graph_check = $validator->validate_graph( $schemas_to_save ); if ( ! $graph_check['valid'] ) { $retry_errors = implode( '; ', $graph_check['errors'] ); $retry_body = $body; $retry_body['content'] = $content . "\n\nVALIDATION ERRORS FROM PREVIOUS ATTEMPT — fix these @id references:\n" . $retry_errors; $retry_response = wp_remote_post( SCHEMA_AI_API_BASE . '/generate', array( 'timeout' => 90, 'headers' => array( 'Content-Type' => 'application/json', 'X-API-Key' => $api_key, ), 'body' => wp_json_encode( $retry_body ), ) ); $retry_ok = false; if ( ! is_wp_error( $retry_response ) ) { $retry_status = wp_remote_retrieve_response_code( $retry_response ); $retry_data = json_decode( wp_remote_retrieve_body( $retry_response ), true ); if ( 200 === $retry_status && ! empty( $retry_data['success'] ) ) { $retry_result = isset( $retry_data['data'] ) ? $retry_data['data'] : array(); $schemas_to_save = array(); if ( ! empty( $retry_result['schemas'] ) && is_array( $retry_result['schemas'] ) ) { foreach ( $retry_result['schemas'] as $item ) { if ( ! empty( $item['schema'] ) && is_array( $item['schema'] ) ) { $schemas_to_save[] = $item['schema']; } } } if ( empty( $schemas_to_save ) && ! empty( $retry_result['schema'] ) ) { $schemas_to_save = array( $retry_result['schema'] ); } // Re-apply entity injection on the retried schemas. if ( $this->should_inject_entity_schema( $post_id ) ) { $schemas_to_save = $this->strip_entity_schemas( $schemas_to_save ); if ( $this->is_homepage_post( $post_id ) ) { array_unshift( $schemas_to_save, $this->build_entity_schema( $post_id ) ); } elseif ( $this->homepage_has_entity_schema() ) { $schemas_to_save = $this->attach_entity_reference( $schemas_to_save ); } } $result = $retry_result; $retry_ok = true; } } if ( ! $retry_ok ) { // Double-fail: save a minimal WebPage as the safest fallback. $fallback_post = get_post( $post_id ); $schemas_to_save = array( array( '@context' => 'https://schema.org', '@type' => 'WebPage', 'name' => $fallback_post ? $fallback_post->post_title : '', 'url' => get_permalink( $post_id ), ), ); } } } $schemas_to_save = $this->strip_invalid_entity_dates( $schemas_to_save ); error_log( 'AI Schema Gen: generate complete — post=' . $post_id . ' schemas_to_save=' . count( $schemas_to_save ) ); foreach ( $schemas_to_save as $index => $schema ) { // Only clear existing schemas on the first schema of the set. // An explicit $schema_type means the caller picked this @type on // purpose (WP admin's "Regenerate as..." or the dashboard's Manage // Schemas) rather than letting the AI classify it — save it as // non-auto-generated so it isn't silently wiped by the next normal // auto-regeneration (see class-auto-apply.php's generate_for_post(), // which only ever deletes is_auto_generated=1 rows). $this->save_schema( $post_id, $schema, $result, 0 === $index, ! empty( $schema_type ) ); } $this->record_usage( $post_id, $result ); return $this->success_response( array( 'success' => true, 'schema' => isset( $result['schema'] ) ? $result['schema'] : null, 'schemas' => isset( $result['schemas'] ) ? $result['schemas'] : null, 'schemaType' => isset( $result['schemaType'] ) ? $result['schemaType'] : null, 'jsonLd' => isset( $result['jsonLd'] ) ? $result['jsonLd'] : null, 'analysis' => isset( $result['analysis'] ) ? $result['analysis'] : null, 'validation' => isset( $result['validation'] ) ? $result['validation'] : null, 'usage' => isset( $result['usage'] ) ? $result['usage'] : null, ) ); } /** * Generate schema for a post ID without going through the REST layer. * * Used by the Bulk Generator (class-bulk-generator.php) so it can call the * same generation logic from a wp_cron context without a WP_REST_Request. * * @param int $post_id The WordPress post ID. * @param string $schema_type Optional specific schema type override — forces * generation to use exactly this @type, skipping * classification (a deliberate human choice, e.g. * Manage Schemas' "add this specific type"). * @param bool $clear_existing Whether to clear the post's existing schema * before saving. False adds this generation's * result alongside whatever schema is already * there instead of replacing it. * @param string $previous_type Optional — the type this page currently has. * Unlike $schema_type, this does NOT force * anything: classification runs normally, and * this is only used afterward to delete the old * entry if the fresh result disagrees with it, * instead of leaving both on the page. Used by * the coverage dashboard's "Regenerate 'X' * Schemas" action, which exists specifically to * correct a wrong type. * @return true|WP_Error True on success, WP_Error on failure. */ public function generate_for_post_id( $post_id, $schema_type = '', $clear_existing = true, $previous_type = '' ) { $post_id = absint( $post_id ); $valid = $this->validate_post_id( $post_id ); if ( is_wp_error( $valid ) ) { return $valid; } $api_key = $this->get_api_key(); if ( empty( $api_key ) ) { return new WP_Error( 'schema_ai_no_api_key', 'AI Schema Gen API key is not configured.' ); } $content = $this->prepare_content( $post_id ); if ( empty( $content ) ) { return new WP_Error( 'schema_ai_empty_content', 'Post content is empty or could not be read.' ); } $secondary_types = array(); if ( $this->count_faq_pairs( $content ) >= 3 ) { $secondary_types[] = 'FAQPage'; } $body = array( 'content' => $content, 'url' => get_permalink( $post_id ), ); $site_id = $this->get_site_id(); if ( $site_id ) { $body['siteId'] = $site_id; } if ( ! empty( $secondary_types ) ) { $body['secondaryTypes'] = $secondary_types; } if ( ! empty( $schema_type ) ) { $body['schemaType'] = $schema_type; } if ( ! empty( $previous_type ) ) { $body['previousType'] = $previous_type; } $response = wp_remote_post( SCHEMA_AI_API_BASE . '/generate', array( 'timeout' => 90, 'headers' => array( 'Content-Type' => 'application/json', 'X-API-Key' => $api_key, ), 'body' => wp_json_encode( $body ), ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'schema_ai_backend_error', $response->get_error_message() ); } $status_code = wp_remote_retrieve_response_code( $response ); $body_raw = wp_remote_retrieve_body( $response ); $data = json_decode( $body_raw, true ); if ( 200 !== $status_code || empty( $data['success'] ) ) { $error_msg = isset( $data['error'] ) ? $data['error'] : 'Schema generation failed.'; return new WP_Error( 'schema_ai_generation_failed', $error_msg ); } $result = isset( $data['data'] ) ? $data['data'] : array(); $schemas_to_save = array(); $this->maybe_sync_plan( $result ); if ( ! empty( $result['schemas'] ) && is_array( $result['schemas'] ) ) { foreach ( $result['schemas'] as $item ) { if ( ! empty( $item['schema'] ) && is_array( $item['schema'] ) ) { $schemas_to_save[] = $item['schema']; } } } if ( empty( $schemas_to_save ) && ! empty( $result['schema'] ) ) { $schemas_to_save = array( $result['schema'] ); } // Option B: for WordPress 'page' post type, strip any AI-guessed business entity // schema and prepend one built entirely from plugin settings (correct @type // guaranteed) — but only on the homepage; every other page gets a lightweight // @id reference instead of a full duplicate block. Skipped entirely when adding // alongside an existing entity schema (clear_existing=false and one is already // saved) — otherwise every "Add" click would stack another duplicate. if ( $this->should_inject_entity_schema( $post_id ) && ( $clear_existing || ! $this->post_has_entity_schema( $post_id ) ) ) { $schemas_to_save = $this->strip_entity_schemas( $schemas_to_save ); if ( $this->is_homepage_post( $post_id ) ) { array_unshift( $schemas_to_save, $this->build_entity_schema( $post_id ) ); } elseif ( $this->homepage_has_entity_schema() ) { $schemas_to_save = $this->attach_entity_reference( $schemas_to_save ); } } // @id cross-reference check — retry once if stubs dangle, WebPage fallback on double-fail. if ( ! empty( $schemas_to_save ) && class_exists( 'Schema_AI_Schema_Validator' ) ) { $validator = new Schema_AI_Schema_Validator(); $graph_check = $validator->validate_graph( $schemas_to_save ); if ( ! $graph_check['valid'] ) { $retry_errors = implode( '; ', $graph_check['errors'] ); $retry_body = $body; $retry_body['content'] = $content . "\n\nVALIDATION ERRORS FROM PREVIOUS ATTEMPT — fix these @id references:\n" . $retry_errors; $retry_response = wp_remote_post( SCHEMA_AI_API_BASE . '/generate', array( 'timeout' => 90, 'headers' => array( 'Content-Type' => 'application/json', 'X-API-Key' => $api_key, ), 'body' => wp_json_encode( $retry_body ), ) ); $retry_ok = false; if ( ! is_wp_error( $retry_response ) ) { $retry_status = wp_remote_retrieve_response_code( $retry_response ); $retry_data = json_decode( wp_remote_retrieve_body( $retry_response ), true ); if ( 200 === $retry_status && ! empty( $retry_data['success'] ) ) { $retry_result = isset( $retry_data['data'] ) ? $retry_data['data'] : array(); $schemas_to_save = array(); if ( ! empty( $retry_result['schemas'] ) && is_array( $retry_result['schemas'] ) ) { foreach ( $retry_result['schemas'] as $item ) { if ( ! empty( $item['schema'] ) && is_array( $item['schema'] ) ) { $schemas_to_save[] = $item['schema']; } } } if ( empty( $schemas_to_save ) && ! empty( $retry_result['schema'] ) ) { $schemas_to_save = array( $retry_result['schema'] ); } // Re-apply entity injection on the retried schemas. Same duplicate-avoidance // as the primary path: skip if adding alongside an existing entity schema. if ( $this->should_inject_entity_schema( $post_id ) && ( $clear_existing || ! $this->post_has_entity_schema( $post_id ) ) ) { $schemas_to_save = $this->strip_entity_schemas( $schemas_to_save ); if ( $this->is_homepage_post( $post_id ) ) { array_unshift( $schemas_to_save, $this->build_entity_schema( $post_id ) ); } elseif ( $this->homepage_has_entity_schema() ) { $schemas_to_save = $this->attach_entity_reference( $schemas_to_save ); } } $result = $retry_result; $retry_ok = true; } } if ( ! $retry_ok ) { $fallback_post = get_post( $post_id ); $schemas_to_save = array( array( '@context' => 'https://schema.org', '@type' => 'WebPage', 'name' => $fallback_post ? $fallback_post->post_title : '', 'url' => get_permalink( $post_id ), ), ); } } } $schemas_to_save = $this->strip_invalid_entity_dates( $schemas_to_save ); foreach ( $schemas_to_save as $index => $schema ) { // Only clear on the first schema of this batch, and only when the // caller actually wants clearing (false = add alongside existing). // $schema_type non-empty means this was a forced type (bulk job's // "add"/"regenerate as X" from Manage Schemas) — see save_schema(). $this->save_schema( $post_id, $schema, $result, $clear_existing && 0 === $index, ! empty( $schema_type ) ); } // $previous_type was a reclassify-in-place request ("this page used to // be type X, is that still right?"), not a force — if the fresh result // landed on a different @type, the old row is now stale and needs // removing explicitly, since $clear_existing is false for this flow // (it must not touch any OTHER schema already on the page). if ( ! empty( $previous_type ) ) { $saved_types = array_map( function ( $s ) { return isset( $s['@type'] ) ? ( is_array( $s['@type'] ) ? $s['@type'][0] : $s['@type'] ) : ''; }, $schemas_to_save ); if ( ! in_array( $previous_type, $saved_types, true ) ) { $this->delete_schema_type( $post_id, $previous_type ); } } $this->record_usage( $post_id, $result ); return true; } /** * Delete a single saved schema type for a post — used to clean up a stale * entry after a "regenerate" reclassifies a page to a different @type than * it had before, instead of leaving both sitting on the page. * * @param int $post_id The WordPress post ID. * @param string $type The schema @type to remove. */ private function delete_schema_type( $post_id, $type ) { global $wpdb; $table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (DELETE), caching not applicable. $wpdb->delete( $table, array( 'post_id' => $post_id, 'schema_type' => $type, ), array( '%d', '%s' ) ); if ( class_exists( 'Schema_AI_Schema_Cache' ) ) { $cache = new Schema_AI_Schema_Cache(); $cache->invalidate( $post_id ); } } /** * Analyse content via the SaaS backend without generating schema. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object or error. */ public function analyze_content( $request ) { $post_id = absint( $request->get_param( 'post_id' ) ); // Validate the post. $valid = $this->validate_post_id( $post_id ); if ( is_wp_error( $valid ) ) { return $valid; } // Get API key. $api_key = $this->get_api_key(); if ( empty( $api_key ) ) { return $this->error_response( 'schema_ai_no_api_key', __( 'AI Schema Gen API key is not configured. Please add it in AI Schema Gen settings.', 'ai-schema-gen' ), 400 ); } // Prepare content. $content = $this->prepare_content( $post_id ); if ( empty( $content ) ) { return $this->error_response( 'schema_ai_empty_content', __( 'Post content is empty or could not be read.', 'ai-schema-gen' ), 400 ); } // Call the SaaS backend. $response = wp_remote_post( SCHEMA_AI_API_BASE . '/generate/analyze', array( 'timeout' => 60, 'headers' => array( 'Content-Type' => 'application/json', 'X-API-Key' => $api_key, ), 'body' => wp_json_encode( array( 'content' => $content ) ), ) ); if ( is_wp_error( $response ) ) { return $this->error_response( 'schema_ai_backend_error', sprintf( /* translators: %s: error message */ __( 'Could not connect to AI Schema Gen server: %s', 'ai-schema-gen' ), $response->get_error_message() ), 502 ); } $status_code = wp_remote_retrieve_response_code( $response ); $body_raw = wp_remote_retrieve_body( $response ); $data = json_decode( $body_raw, true ); if ( 200 !== $status_code || empty( $data['success'] ) ) { $error_msg = isset( $data['error'] ) ? $data['error'] : __( 'Content analysis failed.', 'ai-schema-gen' ); return $this->error_response( 'schema_ai_analysis_failed', $error_msg, $status_code >= 400 ? $status_code : 500 ); } return $this->success_response( array( 'success' => true, 'analysis' => isset( $data['data']['analysis'] ) ? $data['data']['analysis'] : null, 'usage' => isset( $data['data']['usage'] ) ? $data['data']['usage'] : null, ) ); } /** * Permission check for generate and analyse endpoints. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if allowed. */ public function generate_permissions_check( $request ) { // Allow the SaaS backend to call this endpoint directly using the same // API key the plugin has configured — enables backend-driven bulk generation // without relying on wp_cron. $key_header = $request->get_header( 'X-ai-schema-gen-Key' ); if ( $key_header ) { $stored = $this->get_api_key(); if ( ! empty( $stored ) && hash_equals( $stored, $key_header ) ) { return true; } return new WP_Error( 'schema_ai_invalid_key', __( 'Invalid API key.', 'ai-schema-gen' ), array( 'status' => 401 ) ); } if ( ! is_user_logged_in() ) { return new WP_Error( 'schema_ai_not_logged_in', __( 'You must be logged in.', 'ai-schema-gen' ), array( 'status' => 401 ) ); } $post_id = absint( $request->get_param( 'post_id' ) ); if ( $post_id && ! current_user_can( 'edit_post', $post_id ) ) { return new WP_Error( 'schema_ai_forbidden', __( 'You do not have permission to edit this post.', 'ai-schema-gen' ), array( 'status' => 403 ) ); } return true; } /** * Save generated schema to the database. * * @param int $post_id The WordPress post ID. * @param array $schema The generated schema data. * @param array $result Full result from the backend. * @param bool $clear_existing Whether to delete all previous schemas before saving. * Pass false when adding a secondary schema (e.g. FAQPage) * alongside an already-saved primary schema. * @param bool $is_forced_type True when the caller explicitly requested this @type * (WP admin's "Regenerate as..." or the dashboard's Manage * Schemas) rather than letting the AI classify it. Saved as * is_auto_generated=0 so class-auto-apply.php's normal * auto-regeneration (which only deletes is_auto_generated=1 * rows) doesn't silently overwrite a deliberate choice on the * post's next save. */ private function save_schema( $post_id, $schema, $result, $clear_existing = true, $is_forced_type = false ) { global $wpdb; $table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); $schema_type = isset( $schema['@type'] ) ? sanitize_text_field( $schema['@type'] ) : 'Unknown'; $is_auto_generated = $is_forced_type ? 0 : 1; $json_data = wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $confidence = isset( $result['analysis']['confidence'] ) ? (float) $result['analysis']['confidence'] : null; // Validate the schema: must have @context and @type to be considered valid. $validation_status = $this->validate_schema( $schema ); if ( $clear_existing ) { // Delete ALL previously saved schemas for this post before saving the new one. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (DELETE), caching not applicable. $wpdb->delete( $table, array( 'post_id' => $post_id ), array( '%d' ) ); } // Check if a schema already exists for this post and type. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; no WP API alternative exists. $existing = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql() and not user input. "SELECT id FROM {$table} WHERE post_id = %d AND schema_type = %s LIMIT 1", $post_id, $schema_type ) ); if ( $existing ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (UPDATE), caching not applicable. $db_result = $wpdb->update( $table, array( 'schema_data' => $json_data, 'confidence_score' => $confidence, 'is_auto_generated' => $is_auto_generated, 'validation_status' => $validation_status, 'updated_at' => current_time( 'mysql' ), ), array( 'id' => $existing ), array( '%s', '%f', '%d', '%s', '%s' ), array( '%d' ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (INSERT), caching not applicable. $db_result = $wpdb->insert( $table, array( 'post_id' => $post_id, 'schema_type' => $schema_type, 'schema_data' => $json_data, 'is_auto_generated' => $is_auto_generated, 'confidence_score' => $confidence, 'validation_status' => $validation_status, 'created_at' => current_time( 'mysql' ), 'updated_at' => current_time( 'mysql' ), ), array( '%d', '%s', '%s', '%d', '%f', '%s', '%s', '%s' ) ); } if ( false === $db_result ) { error_log( 'AI Schema Gen: SAVE FAILED for post ' . $post_id . ' type=' . $schema_type . ' — ' . $wpdb->last_error ); return; } error_log( 'AI Schema Gen: saved schema OK — post=' . $post_id . ' type=' . $schema_type . ' status=' . $validation_status ); // Invalidate cache. if ( class_exists( 'Schema_AI_Schema_Cache' ) ) { $cache = new Schema_AI_Schema_Cache(); $cache->invalidate( $post_id ); } // Notify the sync engine so the SaaS dashboard reflects this change quickly. do_action( 'schema_ai_schema_saved', $post_id ); } /** * REST callback: generate CollectionPage schema for a taxonomy term archive. * * @param WP_REST_Request $request The REST request. * @return WP_REST_Response|WP_Error */ public function generate_archive_schema( $request ) { $term_id = (int) $request->get_param( 'term_id' ); $taxonomy = (string) $request->get_param( 'taxonomy' ); $result = $this->generate_for_term( $term_id, $taxonomy ); if ( is_wp_error( $result ) ) { return $result; } return new WP_REST_Response( array( 'success' => true, 'data' => $result, ), 200 ); } /** * Generate and save CollectionPage schema for a taxonomy term archive. * * Deterministic, not AI-generated — a term archive has no body content * to analyze, only the term's own name/description and the posts it * lists, the same reasoning as the entity-authority Organization/ * LocalBusiness builders. Mirrors generate_for_post_id()'s shape * (build -> validate -> save) but skips every AI/content-extraction step. * * @param int $term_id WordPress term ID. * @param string $taxonomy Taxonomy slug (e.g. 'category', 'post_tag'). * @return array|WP_Error { schema_type, validation_status, url } on success. */ public function generate_for_term( $term_id, $taxonomy ) { $term = get_term( $term_id, $taxonomy ); if ( ! $term || is_wp_error( $term ) ) { return new WP_Error( 'schema_ai_invalid_term', __( 'That term does not exist.', 'ai-schema-gen' ), array( 'status' => 404 ) ); } if ( ! class_exists( 'Schema_AI_Schema_Factory' ) ) { return new WP_Error( 'schema_ai_factory_unavailable', __( 'Schema factory unavailable.', 'ai-schema-gen' ), array( 'status' => 500 ) ); } $builder = Schema_AI_Schema_Factory::get_builder( 'CollectionPage' ); if ( ! $builder ) { return new WP_Error( 'schema_ai_builder_unavailable', __( 'CollectionPage builder unavailable.', 'ai-schema-gen' ), array( 'status' => 500 ) ); } $schema = $builder->build( $term_id, $taxonomy ); if ( empty( $schema ) ) { return new WP_Error( 'schema_ai_build_failed', __( 'Could not build schema for this term (it may not have a public archive page).', 'ai-schema-gen' ), array( 'status' => 400 ) ); } $validation_status = $this->validate_schema( $schema ); $this->save_archive_schema( $term_id, $taxonomy, $schema, $validation_status ); return array( 'schema_type' => $schema['@type'], 'validation_status' => $validation_status, 'url' => isset( $schema['url'] ) ? $schema['url'] : '', ); } /** * Upsert a term's CollectionPage schema into schema_ai_archive_schemas. * * One row per term (unlike posts, which can carry several schema types * at once) — the UNIQUE KEY on (taxonomy, term_id) enforces this. * * @param int $term_id WordPress term ID. * @param string $taxonomy Taxonomy slug. * @param array $schema The built schema array. * @param string $validation_status 'valid', 'warning', or 'invalid'. */ private function save_archive_schema( $term_id, $taxonomy, $schema, $validation_status ) { global $wpdb; $table = esc_sql( $wpdb->prefix . 'schema_ai_archive_schemas' ); $schema_type = isset( $schema['@type'] ) ? sanitize_text_field( $schema['@type'] ) : 'Unknown'; $json_data = wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; no WP API alternative exists. $existing = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql() and not user input. "SELECT id FROM {$table} WHERE taxonomy = %s AND term_id = %d LIMIT 1", $taxonomy, $term_id ) ); if ( $existing ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (UPDATE), caching not applicable. $wpdb->update( $table, array( 'schema_type' => $schema_type, 'schema_data' => $json_data, 'validation_status' => $validation_status, 'updated_at' => current_time( 'mysql' ), ), array( 'id' => $existing ), array( '%s', '%s', '%s', '%s' ), array( '%d' ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (INSERT), caching not applicable. $wpdb->insert( $table, array( 'taxonomy' => $taxonomy, 'term_id' => $term_id, 'schema_type' => $schema_type, 'schema_data' => $json_data, 'is_auto_generated' => 1, 'validation_status' => $validation_status, 'created_at' => current_time( 'mysql' ), 'updated_at' => current_time( 'mysql' ), ), array( '%s', '%d', '%s', '%s', '%d', '%s', '%s', '%s' ) ); } } /** * Validate a schema object. * * Checks for required JSON-LD fields (@context, @type) and basic structure. * * @param array $schema The schema data. * @return string 'valid', 'warning', or 'invalid'. */ private function validate_schema( $schema ) { // Single implementation, shared with the Entity-Profile-change refresh // path — see Schema_AI_Entity_Schema_Builder::validation_status(). return Schema_AI_Entity_Schema_Builder::validation_status( $schema ); } /** * Whether to inject a settings-built entity schema for this post. * * Delegates to Schema_AI_Entity_Schema_Builder so the generate path and * the Entity-Profile-change refresh path (Schema_AI_Entity_Refresh) share * one implementation and can't drift. * * @param int $post_id * @return bool */ private function should_inject_entity_schema( $post_id ) { return Schema_AI_Entity_Schema_Builder::should_inject( $post_id ); } /** * Build the entity node entirely from plugin settings. @type comes from * the configured business subtype — never from AI. Delegates to * Schema_AI_Entity_Schema_Builder (see should_inject_entity_schema()). * * @param int $post_id * @return array Schema array, with @context set. */ private function build_entity_schema( $post_id ) { return Schema_AI_Entity_Schema_Builder::build( $post_id ); } /** * Remove AI-generated business entity schemas from the set. * Replaced by the settings-built version from build_entity_schema(). * * @param array $schemas * @return array Filtered schemas. */ private function strip_entity_schemas( $schemas ) { $entity_types = array( 'Organization', 'LocalBusiness', 'Corporation' ); // Real businesses are usually saved as their specific subtype (e.g. // "GeneralContractor"), not the literal string "LocalBusiness" — check // against what this site's settings would actually resolve to. Without // this, a service-area business's own AI-generated entity guess (no // address, so the PostalAddress check below misses it too) survives // stripping, sits in the array alongside the correctly-built // settings-based entity, and silently overwrites it in the DB — // save_schema() matches existing rows by (post_id, schema_type), and // both carry the same @type, so whichever is processed second wins. // Confirmed live: this is exactly why a general contractor's homepage // entity kept losing its @id after every regenerate. Mirrors the same // fix post_has_entity_schema() already has, just below. if ( class_exists( 'Schema_AI_Business_Subtype' ) ) { $entity_types[] = Schema_AI_Business_Subtype::get_type_from_settings(); } return array_values( array_filter( $schemas, function( $schema ) use ( $entity_types ) { $type = isset( $schema['@type'] ) ? ( is_array( $schema['@type'] ) ? $schema['@type'][0] : $schema['@type'] ) : ''; if ( in_array( $type, $entity_types, true ) ) { return false; } // Strip any schema carrying a PostalAddress — reliable signal it is a business entity schema. if ( isset( $schema['address']['@type'] ) && 'PostalAddress' === $schema['address']['@type'] ) { return false; } return true; } ) ); } /** * Strip datePublished/dateModified from any business-entity-typed schema. * * schema.org doesn't define these on LocalBusiness/Organization/Corporation * or any LocalBusiness subtype — only on content types (Article, WebPage, * etc). The AI includes them anyway on occasion despite the prompt now * saying not to (see prepare_content()'s date instructions), so this is a * deterministic backstop, same reasoning as strip_entity_schemas() not * fully trusting the AI to self-police structural correctness. Runs on * every save regardless of whether build_entity_schema() ran, since a * site with no org settings configured falls back entirely to the AI's * own entity guess. * * @param array $schemas * @return array */ private function strip_invalid_entity_dates( $schemas ) { $entity_types = array( 'Organization', 'LocalBusiness', 'Corporation' ); if ( class_exists( 'Schema_AI_Business_Subtype' ) ) { $entity_types[] = Schema_AI_Business_Subtype::get_type_from_settings(); } foreach ( $schemas as &$schema ) { $type = isset( $schema['@type'] ) ? ( is_array( $schema['@type'] ) ? $schema['@type'][0] : $schema['@type'] ) : ''; if ( in_array( $type, $entity_types, true ) ) { unset( $schema['datePublished'], $schema['dateModified'] ); } } unset( $schema ); return $schemas; } /** * Whether this post already has a saved entity-type schema (Organization, * LocalBusiness, Corporation, or a LocalBusiness subtype with a * PostalAddress). Used to avoid stacking duplicate entity schemas when * "Add" generates alongside existing schema instead of replacing it. * * @param int $post_id * @return bool */ private function post_has_entity_schema( $post_id ) { global $wpdb; $table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; no WP API alternative exists. $rows = $wpdb->get_col( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql() and not user input. "SELECT schema_data FROM {$table} WHERE post_id = %d", $post_id ) ); foreach ( $rows as $schema_json ) { $schema = json_decode( $schema_json, true ); if ( ! is_array( $schema ) ) { continue; } $type = isset( $schema['@type'] ) ? ( is_array( $schema['@type'] ) ? $schema['@type'][0] : $schema['@type'] ) : ''; $entity_types = array( 'Organization', 'LocalBusiness', 'Corporation' ); // Real businesses are usually saved as their specific subtype (e.g. // "GeneralContractor"), not the literal string "LocalBusiness" — check // against what this site's settings would actually resolve to. if ( class_exists( 'Schema_AI_Business_Subtype' ) ) { $entity_types[] = Schema_AI_Business_Subtype::get_type_from_settings(); } if ( in_array( $type, $entity_types, true ) ) { return true; } if ( isset( $schema['address']['@type'] ) && 'PostalAddress' === $schema['address']['@type'] ) { return true; } } return false; } /** * Whether $post_id is the site's homepage — its permalink resolves to the * site root, regardless of whether "Settings > Reading" uses a static * front page or the default posts index. * * @param int $post_id * @return bool */ private function is_homepage_post( $post_id ) { return Schema_AI_Entity_Schema_Builder::is_homepage_post( $post_id ); } /** * Whether the homepage already has a saved entity-type schema row. * * Used to decide whether other pages should carry a lightweight @id * reference back to it instead of duplicating the full block — until the * homepage itself has been generated at least once, no reference is added * anywhere (nothing to point to yet), rather than falling back to the old * behaviour of duplicating the full entity block on every page. * * @return bool */ private function homepage_has_entity_schema() { $home_post_id = url_to_postid( home_url( '/' ) ); if ( ! $home_post_id ) { return false; } return $this->post_has_entity_schema( $home_post_id ); } /** * Attach a lightweight `{"@id": "..."}` publisher reference to the primary * (first) schema in the set instead of duplicating the full entity block — * matches Google's own guidance against repeating the full Organization/ * LocalBusiness block on every page ("place this on your home page... you * don't need to include it on every page"). * * @param array $schemas * @return array */ private function attach_entity_reference( $schemas ) { if ( empty( $schemas ) || ! empty( $schemas[0]['publisher'] ) ) { return $schemas; } $schemas[0]['publisher'] = Schema_AI_ID_Builder::ref( Schema_AI_ID_Builder::primary_entity() ); return $schemas; } /** * Sync the user's plan from the backend response to WP user meta. * * Called after every successful generation so the plan shown in the plugin * always reflects the current subscription without requiring a manual * "Verify Connection" click. No-op when no WP session exists (bulk/cron). * * @param array $result Full data block from the backend generate response. */ private function maybe_sync_plan( $result ) { $user_id = get_current_user_id(); if ( ! $user_id ) { return; } if ( empty( $result['plan'] ) ) { return; } $new_plan = sanitize_text_field( $result['plan'] ); $stored_plan = get_user_meta( $user_id, 'schema_ai_plan', true ); if ( $new_plan !== $stored_plan ) { update_user_meta( $user_id, 'schema_ai_plan', $new_plan ); } } /** * Record a successful generation in the usage and generations tables. * * @param int $post_id The WordPress post ID. * @param array $result Full result from the backend. */ private function record_usage( $post_id, $result ) { global $wpdb; // Bulk generation and backend-driven calls have no WP user session. // Fall back to the first administrator so stats appear on the dashboard. $user_id = get_current_user_id(); if ( ! $user_id ) { $admins = get_users( array( 'role' => 'administrator', 'number' => 1, 'fields' => 'ID', 'orderby' => 'ID' ) ); $user_id = ! empty( $admins ) ? (int) $admins[0] : 0; } $today = current_time( 'Y-m-d' ); // ── 1. Increment daily counter in schema_ai_usage ── $usage_table = esc_sql( $wpdb->prefix . 'schema_ai_usage' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; no WP API alternative exists. $existing = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql() and not user input. "SELECT id FROM {$usage_table} WHERE user_id = %d AND date = %s LIMIT 1", $user_id, $today ) ); if ( $existing ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write/DDL operation, caching not applicable. $update_result = $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql() and not user input. "UPDATE {$usage_table} SET ai_generations = ai_generations + 1 WHERE id = %d", $existing ) ); if ( false === $update_result ) { error_log( 'AI Schema Gen: record_usage UPDATE failed — user=' . $user_id . ' date=' . $today . ' error=' . $wpdb->last_error ); } else { error_log( 'AI Schema Gen: record_usage UPDATE OK — user=' . $user_id . ' date=' . $today ); } } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (INSERT), caching not applicable. $insert_result = $wpdb->insert( $usage_table, array( 'user_id' => $user_id, 'date' => $today, 'ai_generations' => 1, ), array( '%d', '%s', '%d' ) ); if ( false === $insert_result ) { error_log( 'AI Schema Gen: record_usage INSERT failed — user=' . $user_id . ' date=' . $today . ' error=' . $wpdb->last_error ); } else { error_log( 'AI Schema Gen: record_usage INSERT OK — user=' . $user_id . ' date=' . $today ); } } // ── 2. Log individual generation in schema_ai_generations ── $gen_table = esc_sql( $wpdb->prefix . 'schema_ai_generations' ); $usage_data = isset( $result['usage'] ) ? $result['usage'] : array(); $ai_provider = isset( $usage_data['provider'] ) ? sanitize_text_field( $usage_data['provider'] ) : 'openai'; $ai_model = isset( $usage_data['model'] ) ? sanitize_text_field( $usage_data['model'] ) : ''; $input_tokens = isset( $usage_data['inputTokens'] ) ? (int) $usage_data['inputTokens'] : 0; $output_tokens = isset( $usage_data['outputTokens'] ) ? (int) $usage_data['outputTokens'] : 0; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (INSERT), caching not applicable. $gen_result = $wpdb->insert( $gen_table, array( 'user_id' => $user_id, 'post_id' => $post_id, 'ai_provider' => $ai_provider, 'ai_model' => $ai_model, 'input_tokens' => $input_tokens, 'output_tokens' => $output_tokens, 'success' => 1, 'created_at' => current_time( 'mysql' ), ), array( '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s' ) ); if ( false === $gen_result ) { error_log( 'AI Schema Gen: generations INSERT failed — post=' . $post_id . ' user=' . $user_id . ' error=' . $wpdb->last_error ); } else { error_log( 'AI Schema Gen: generations INSERT OK — post=' . $post_id . ' user=' . $user_id . ' provider=' . $ai_provider ); } } }