'name', 'title' => 'name', 'full_name' => 'name', 'display_name' => 'name', // Description. 'description' => 'description', 'short_description' => 'description', 'summary' => 'description', 'excerpt' => 'description', 'about' => 'description', // Pricing. 'price' => 'price', 'cost' => 'price', 'amount' => 'price', 'sale_price' => 'price', 'regular_price' => 'price', 'currency' => 'priceCurrency', // Contact. 'phone' => 'telephone', 'telephone' => 'telephone', 'phone_number' => 'telephone', 'mobile' => 'telephone', 'email' => 'email', 'email_address' => 'email', // Location. 'address' => 'address', 'street_address' => 'streetAddress', 'city' => 'addressLocality', 'state' => 'addressRegion', 'zip' => 'postalCode', 'zip_code' => 'postalCode', 'postal_code' => 'postalCode', 'country' => 'addressCountry', // Media. 'image' => 'image', 'photo' => 'image', 'picture' => 'image', 'logo' => 'logo', 'video' => 'video', 'video_url' => 'video', // URLs. 'url' => 'url', 'website' => 'url', 'link' => 'url', 'website_url' => 'url', // Dates. 'start_date' => 'startDate', 'end_date' => 'endDate', 'date' => 'datePublished', 'event_date' => 'startDate', 'published_date' => 'datePublished', 'modified_date' => 'dateModified', // Time. 'duration' => 'duration', 'time' => 'duration', 'prep_time' => 'prepTime', 'cook_time' => 'cookTime', 'total_time' => 'totalTime', // Organisation. 'company' => 'name', 'organization' => 'name', 'organisation' => 'name', 'brand' => 'brand', // Product. 'sku' => 'sku', 'gtin' => 'gtin', 'mpn' => 'mpn', 'weight' => 'weight', 'color' => 'color', 'colour' => 'color', 'material' => 'material', 'size' => 'size', // Person. 'job_title' => 'jobTitle', 'job' => 'jobTitle', 'position' => 'jobTitle', 'first_name' => 'givenName', 'last_name' => 'familyName', // Rating. 'rating' => 'ratingValue', 'rating_value' => 'ratingValue', 'review_count' => 'reviewCount', // Recipe. 'servings' => 'recipeYield', 'yield' => 'recipeYield', 'recipe_yield' => 'recipeYield', 'calories' => 'calories', 'cuisine' => 'recipeCuisine', 'category' => 'recipeCategory', 'ingredients' => 'recipeIngredient', // Opening hours. 'opening_hours' => 'openingHours', 'hours' => 'openingHours', 'business_hours' => 'openingHours', ); /** * Initialise the ACF integration. * * Called on the 'acf/init' action so we know ACF functions are available. */ public function init() { if ( ! $this->is_acf_active() ) { return; } add_filter( 'schema_ai_field_data', array( $this, 'add_acf_fields' ), 10, 3 ); // Register ACF options page field group for schema field mapping. add_action( 'acf/include_fields', array( $this, 'register_acf_field_group' ) ); } /** * Add ACF field values to the schema field data. * * Reads the user-configured field mapping from plugin options and * merges ACF field values into the $fields array. If no explicit * mapping exists, attempts auto-detection based on field names. * * @param array $fields The current field data array. * @param int $post_id The WordPress post ID. * @param string $schema_type The schema.org type. * @return array Modified field data with ACF values merged in. */ public function add_acf_fields( $fields, $post_id, $schema_type ) { if ( ! function_exists( 'get_field' ) ) { return $fields; } // Get the user-configured mapping. $mapping = $this->get_field_mapping(); // If no explicit mapping, try auto-detection. if ( empty( $mapping ) ) { $mapping = $this->auto_detect_mapping( $post_id, $schema_type ); } if ( empty( $mapping ) || ! is_array( $mapping ) ) { return $fields; } // Apply each mapping. foreach ( $mapping as $acf_field_name => $schema_property ) { if ( empty( $acf_field_name ) || empty( $schema_property ) ) { continue; } // Get the ACF field value. $value = get_field( $acf_field_name, $post_id ); if ( null === $value || '' === $value ) { continue; } // Handle ACF image fields (returns array with url, id, etc.). if ( is_array( $value ) && isset( $value['url'] ) ) { $value = $value['url']; } // Handle ACF link fields (returns array with url, title, target). if ( is_array( $value ) && isset( $value['url'] ) && ! isset( $value['id'] ) ) { $value = $value['url']; } // Handle ACF date fields (returns raw date string). if ( in_array( $schema_property, array( 'startDate', 'endDate', 'datePublished', 'dateModified' ), true ) ) { $timestamp = strtotime( $value ); if ( false !== $timestamp ) { $value = gmdate( 'c', $timestamp ); } } // Only add if the field is not already set in schema data. if ( ! isset( $fields[ $schema_property ] ) || empty( $fields[ $schema_property ] ) ) { $fields[ $schema_property ] = $value; } } return $fields; } /** * Get all ACF fields and their values for a post. * * Uses ACF's get_fields() function to retrieve all custom field * values associated with a post. * * @param int $post_id The WordPress post ID. * @return array Associative array of field_name => value pairs. */ public function get_acf_fields_for_post( $post_id ) { if ( ! function_exists( 'get_fields' ) ) { return array(); } $fields = get_fields( $post_id ); if ( empty( $fields ) || ! is_array( $fields ) ) { return array(); } return $fields; } /** * Get the stored ACF-to-schema field mapping from plugin options. * * The mapping is stored as part of the schema_ai_settings option * under the 'acf_field_mapping' key. Format: * ['acf_field_name' => 'schema_property', ...] * * @return array The field mapping array, or empty array if not configured. */ public function get_field_mapping() { $settings = get_option( 'schema_ai_settings', array() ); if ( isset( $settings['acf_field_mapping'] ) && is_array( $settings['acf_field_mapping'] ) ) { return $settings['acf_field_mapping']; } return array(); } /** * Save the ACF-to-schema field mapping to plugin options. * * Merges the mapping into the existing schema_ai_settings option. * * @param array $mapping The field mapping array. * @return bool True on success, false on failure. */ public function save_field_mapping( $mapping ) { if ( ! is_array( $mapping ) ) { return false; } // Sanitise the mapping values. $sanitised = array(); foreach ( $mapping as $acf_field => $schema_property ) { $clean_field = sanitize_text_field( $acf_field ); $clean_property = sanitize_text_field( $schema_property ); if ( ! empty( $clean_field ) && ! empty( $clean_property ) ) { $sanitised[ $clean_field ] = $clean_property; } } $settings = get_option( 'schema_ai_settings', array() ); $settings['acf_field_mapping'] = $sanitised; return update_option( 'schema_ai_settings', $settings ); } /** * Auto-detect field mapping based on common ACF field names. * * Examines all ACF fields attached to a post and attempts to match * them to schema.org properties by comparing field names against the * known common mappings list. * * @param int $post_id The WordPress post ID. * @param string $schema_type The schema.org type being generated. * @return array Suggested mapping of ACF field names to schema properties. */ public function auto_detect_mapping( $post_id, $schema_type ) { $acf_fields = $this->get_acf_fields_for_post( $post_id ); if ( empty( $acf_fields ) ) { return array(); } $suggested = array(); foreach ( $acf_fields as $field_name => $value ) { // Skip empty values. if ( null === $value || '' === $value ) { continue; } // Normalise the field name for matching (lowercase, underscores). $normalised = strtolower( str_replace( array( '-', ' ' ), '_', $field_name ) ); // Direct match against common mappings. if ( isset( $this->common_mappings[ $normalised ] ) ) { $suggested[ $field_name ] = $this->common_mappings[ $normalised ]; continue; } // Partial match: check if the normalised name contains a known key. foreach ( $this->common_mappings as $pattern => $property ) { if ( false !== strpos( $normalised, $pattern ) ) { // Avoid overwriting a more specific match. if ( ! isset( $suggested[ $field_name ] ) ) { $suggested[ $field_name ] = $property; } break; } } } // Filter suggestions based on schema type relevance. $suggested = $this->filter_by_schema_type( $suggested, $schema_type ); return $suggested; } /** * Filter auto-detected mappings to only include properties relevant * to the given schema type. * * For example, recipeIngredient is only relevant for Recipe schemas, * and offers/price is only relevant for Product schemas. * * @param array $mapping The suggested mapping. * @param string $schema_type The schema.org type. * @return array Filtered mapping. */ private function filter_by_schema_type( $mapping, $schema_type ) { // Define which properties are relevant to each schema type. $type_properties = array( 'Product' => array( 'name', 'description', 'image', 'price', 'priceCurrency', 'sku', 'gtin', 'mpn', 'brand', 'color', 'material', 'weight', 'size', 'url', 'ratingValue', 'reviewCount' ), 'Article' => array( 'name', 'description', 'image', 'datePublished', 'dateModified', 'url' ), 'BlogPosting' => array( 'name', 'description', 'image', 'datePublished', 'dateModified', 'url' ), 'Recipe' => array( 'name', 'description', 'image', 'prepTime', 'cookTime', 'totalTime', 'recipeYield', 'recipeIngredient', 'recipeCuisine', 'recipeCategory', 'calories', 'url' ), 'Event' => array( 'name', 'description', 'image', 'startDate', 'endDate', 'url', 'address', 'streetAddress', 'addressLocality', 'addressRegion', 'postalCode', 'addressCountry' ), 'LocalBusiness' => array( 'name', 'description', 'image', 'telephone', 'email', 'url', 'address', 'streetAddress', 'addressLocality', 'addressRegion', 'postalCode', 'addressCountry', 'openingHours', 'logo' ), 'Person' => array( 'name', 'givenName', 'familyName', 'description', 'image', 'email', 'telephone', 'url', 'jobTitle' ), 'Organization' => array( 'name', 'description', 'image', 'logo', 'telephone', 'email', 'url', 'address', 'streetAddress', 'addressLocality', 'addressRegion', 'postalCode', 'addressCountry' ), 'Service' => array( 'name', 'description', 'image', 'url', 'price', 'priceCurrency', 'telephone', 'email' ), 'Course' => array( 'name', 'description', 'image', 'url', 'duration', 'price', 'priceCurrency' ), ); // If we don't know the schema type's properties, return all mappings. if ( ! isset( $type_properties[ $schema_type ] ) ) { return $mapping; } $allowed = $type_properties[ $schema_type ]; return array_filter( $mapping, function ( $property ) use ( $allowed ) { return in_array( $property, $allowed, true ); } ); } /** * Register an ACF field group for schema field mapping configuration. * * This creates an optional ACF options sub-page where users can * visually configure field-to-schema mappings using ACF's repeater * field (if ACF Pro is available). */ public function register_acf_field_group() { if ( ! function_exists( 'acf_add_local_field_group' ) ) { return; } acf_add_local_field_group( array( 'key' => 'group_schema_ai_acf_mapping', 'title' => __( 'AI Schema Gen - ACF Field Mapping', 'ai-schema-gen' ), 'fields' => array( array( 'key' => 'field_schema_ai_mapping_instructions', 'label' => __( 'Field Mapping Instructions', 'ai-schema-gen' ), 'name' => '', 'type' => 'message', 'message' => __( 'Map your ACF fields to schema.org properties below. Leave empty to use automatic detection. The field name should match your ACF field name exactly, and the schema property should be a valid schema.org property name (e.g. "name", "description", "telephone", "price").', 'ai-schema-gen' ), ), array( 'key' => 'field_schema_ai_acf_mappings', 'label' => __( 'Field Mappings', 'ai-schema-gen' ), 'name' => 'schema_ai_acf_mappings', 'type' => 'repeater', 'layout' => 'table', 'button_label' => __( 'Add Mapping', 'ai-schema-gen' ), 'sub_fields' => array( array( 'key' => 'field_schema_ai_acf_field_name', 'label' => __( 'ACF Field Name', 'ai-schema-gen' ), 'name' => 'acf_field_name', 'type' => 'text', 'placeholder' => 'e.g. phone_number', 'wrapper' => array( 'width' => '50' ), ), array( 'key' => 'field_schema_ai_schema_property', 'label' => __( 'Schema Property', 'ai-schema-gen' ), 'name' => 'schema_property', 'type' => 'text', 'placeholder' => 'e.g. telephone', 'wrapper' => array( 'width' => '50' ), ), ), ), ), 'location' => array( array( array( 'param' => 'options_page', 'operator' => '==', 'value' => 'ai-schema-gen-settings', ), ), ), 'menu_order' => 100, 'position' => 'normal', 'style' => 'default', 'label_placement' => 'top', 'instruction_placement' => 'label', 'active' => true, ) ); } /** * Check if Advanced Custom Fields is active. * * Checks for both the ACF class (Pro and Free) and the get_field() * function which may be available via other ACF-compatible plugins. * * @return bool True if ACF is active. */ public function is_acf_active() { return class_exists( 'ACF' ) || function_exists( 'get_field' ); } }