Plugin Directory

Changeset 3433224


Ignore:
Timestamp:
01/06/2026 05:11:10 AM (3 months ago)
Author:
synoveo
Message:

Deploy synoveo v2.3.1

Location:
synoveo/trunk
Files:
14 added
6 edited

Legend:

Unmodified
Added
Removed
  • synoveo/trunk/direct-api.php

    r3424860 r3433224  
    11<?php
    22/**
    3  * Direct API endpoint for Synoveo - Ultra-fast data extraction.
     3 * Direct API endpoint for Synoveo - Ultra-fast data extraction and import.
    44 *
    55 * This file provides high-performance REST API responses by using WordPress
    66 * SHORTINIT mode, loading only the database layer without plugins or themes.
    7  * This results in significantly faster response times for data extraction.
     7 * This results in significantly faster response times for data operations.
    88 *
    99 * @package Synoveo
     
    1111 *
    1212 * Usage (from plugin directory):
    13  *   /wp-content/plugins/synoveo/direct-api.php?route=/synoveo/v1/snapshot
    14  *   /wp-content/plugins/synoveo/direct-api.php?route=/synoveo/v1/sources/{plugin}/{field}
     13 *   GET  /wp-content/plugins/synoveo/direct-api.php?route=/synoveo/v1/snapshot
     14 *   GET  /wp-content/plugins/synoveo/direct-api.php?route=/synoveo/v1/sources/{plugin}/{field}
     15 *   POST /wp-content/plugins/synoveo/direct-api.php?route=/synoveo/v1/import
     16 *        Body: { "sources": { "plugin-key": { "gbp.field": value } } }
     17 *   GET  /wp-content/plugins/synoveo/direct-api.php?route=/synoveo/v1/debug
    1518 */
    1619
     
    4649    // Pattern 2: /synoveo/v1/sources (list all).
    4750    $route_valid = true;
     51} elseif ( '/synoveo/v1/import' === $route ) {
     52    // Pattern 3: /synoveo/v1/import (POST: write GBP data to WordPress).
     53    $route_valid = true;
     54} elseif ( '/synoveo/v1/debug' === $route ) {
     55    // Pattern 4: /synoveo/v1/debug (diagnostic info).
     56    $route_valid = true;
    4857} elseif ( preg_match( '#^/synoveo/v1/sources/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_./]+)$#', $route, $matches ) ) {
    49     // Pattern 3: /synoveo/v1/sources/{plugin}/{field}.
     58    // Pattern 5: /synoveo/v1/sources/{plugin}/{field}.
    5059    // Field can contain dots, slashes and uppercase (e.g., profile.description, attributes/url_instagram).
    5160    $route_valid   = true;
     
    160169require_once __DIR__ . '/includes/services/class-synoveo-plugin-registry.php';
    161170
     171// Load modular plugin mappings infrastructure.
     172require_once __DIR__ . '/includes/services/class-synoveo-option-batch-reader.php';
     173require_once __DIR__ . '/includes/services/class-synoveo-option-batch-writer.php';
     174require_once __DIR__ . '/includes/services/class-synoveo-value-transforms.php';
     175require_once __DIR__ . '/includes/mappings/index.php';
     176
    162177/**
    163178 * Check if a plugin is active by reading the active_plugins option.
     
    376391
    377392/**
     393 * Extract business hours from Amelia Booking weekSchedule.
     394 * Converts Amelia's format to GBP regularHours format.
     395 *
     396 * Amelia format: [{ "day": "Monday", "time": ["07:00", "20:00"] }, ...]
     397 * GBP format: { periods: [{ openDay: "MONDAY", openTime: {hours: 7, minutes: 0}, ... }] }
     398 *
     399 * @param array $amelia_settings Parsed amelia_settings JSON option.
     400 * @return array|null GBP regularHours format or null if no hours found.
     401 */
     402function synoveo_direct_extract_amelia_hours( $amelia_settings ) {
     403    if ( empty( $amelia_settings ) || ! is_array( $amelia_settings ) ) {
     404        return null;
     405    }
     406
     407    $week_schedule = $amelia_settings['weekSchedule'] ?? null;
     408    if ( empty( $week_schedule ) || ! is_array( $week_schedule ) ) {
     409        return null;
     410    }
     411
     412    $day_map = array(
     413        'Monday'    => 'MONDAY',
     414        'Tuesday'   => 'TUESDAY',
     415        'Wednesday' => 'WEDNESDAY',
     416        'Thursday'  => 'THURSDAY',
     417        'Friday'    => 'FRIDAY',
     418        'Saturday'  => 'SATURDAY',
     419        'Sunday'    => 'SUNDAY',
     420    );
     421
     422    $periods = array();
     423    foreach ( $week_schedule as $entry ) {
     424        $day  = $entry['day'] ?? '';
     425        $time = $entry['time'] ?? array();
     426
     427        if ( empty( $day ) || empty( $time ) || ! is_array( $time ) || count( $time ) < 2 ) {
     428            continue;
     429        }
     430
     431        $gbp_day = $day_map[ $day ] ?? null;
     432        if ( ! $gbp_day ) {
     433            continue;
     434        }
     435
     436        $open_time  = $time[0] ?? '';
     437        $close_time = $time[1] ?? '';
     438
     439        if ( empty( $open_time ) || empty( $close_time ) ) {
     440            continue;
     441        }
     442
     443        $periods[] = array(
     444            'openDay'   => $gbp_day,
     445            'openTime'  => synoveo_time_to_gbp_object( $open_time ),
     446            'closeDay'  => $gbp_day,
     447            'closeTime' => synoveo_time_to_gbp_object( $close_time ),
     448        );
     449    }
     450
     451    return ! empty( $periods ) ? array( 'periods' => $periods ) : null;
     452}
     453
     454/**
     455 * Extract business hours from Bookly individual options.
     456 * Converts Bookly's format to GBP regularHours format.
     457 *
     458 * Bookly stores hours in individual options:
     459 * - bookly_bh_monday_start, bookly_bh_monday_end, etc.
     460 *
     461 * @param array $prefetched Prefetched options from batch reader.
     462 * @return array|null GBP regularHours format or null if no hours found.
     463 */
     464function synoveo_direct_extract_bookly_hours( $prefetched ) {
     465    $days    = array( 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday' );
     466    $day_map = array(
     467        'monday'    => 'MONDAY',
     468        'tuesday'   => 'TUESDAY',
     469        'wednesday' => 'WEDNESDAY',
     470        'thursday'  => 'THURSDAY',
     471        'friday'    => 'FRIDAY',
     472        'saturday'  => 'SATURDAY',
     473        'sunday'    => 'SUNDAY',
     474    );
     475
     476    $periods = array();
     477    foreach ( $days as $day ) {
     478        $start_key = "bookly_bh_{$day}_start";
     479        $end_key   = "bookly_bh_{$day}_end";
     480
     481        $open_time  = $prefetched[ $start_key ] ?? '';
     482        $close_time = $prefetched[ $end_key ] ?? '';
     483
     484        // Skip if no hours set for this day (closed).
     485        if ( empty( $open_time ) || empty( $close_time ) ) {
     486            continue;
     487        }
     488
     489        $periods[] = array(
     490            'openDay'   => $day_map[ $day ],
     491            'openTime'  => synoveo_time_to_gbp_object( $open_time ),
     492            'closeDay'  => $day_map[ $day ],
     493            'closeTime' => synoveo_time_to_gbp_object( $close_time ),
     494        );
     495    }
     496
     497    return ! empty( $periods ) ? array( 'periods' => $periods ) : null;
     498}
     499
     500/**
    378501 * Get list of active plugins that we support.
    379502 * Uses Plugin_Registry as the SINGLE SOURCE OF TRUTH for plugin file mappings.
     
    407530
    408531/**
    409  * Extract all business data from wp_options (no plugin loading needed!)
    410  * IMPORTANT: Only extracts data from ACTIVE plugins to avoid orphaned data confusion
     532 * Get list of active plugins from modular registry.
     533 * Dynamically checks plugin activation - no hardcoding needed.
     534 *
     535 * @return string[] Active plugin keys.
     536 */
     537function synoveo_get_active_plugin_keys() {
     538    // Always-on plugins (no plugin_file check needed).
     539    $active = array( 'wordpress-core', 'synoveo' );
     540
     541    // Check each registered plugin for activation.
     542    $all_mappings = SYNOVEO_Plugin_Mappings::load_all();
     543    foreach ( $all_mappings as $key => $mapping ) {
     544        if ( in_array( $key, $active, true ) ) {
     545            continue;
     546        }
     547
     548        $plugin_file = isset( $mapping['plugin_file'] ) ? $mapping['plugin_file'] : null;
     549        // Pass the plugin KEY (slug), not the file path.
     550        // synoveo_direct_is_plugin_active() expects a slug and looks up the file path internally.
     551        if ( $plugin_file && synoveo_direct_is_plugin_active( $key ) ) {
     552            $active[] = $key;
     553        }
     554    }
     555
     556    return $active;
     557}
     558
     559/**
     560 * Export plugin snapshot data using modular mappings.
     561 * Uses batch reader for optimized DB access (1 query vs N queries).
     562 * Applies transform layer for value normalization.
     563 * Re-filters after transforms to remove null/empty values.
     564 *
     565 * IMPORTANT: Also handles special fields that aren't stored in wp_options:
     566 * - regularHours: Yoast stores as individual opening_hours_{day}_{from/to} options
     567 * - serviceItems: Stored in database tables (Amelia, Bookly, WooCommerce)
     568 *
     569 * @param wpdb     $wpdb           WordPress database object.
     570 * @param string[] $active_plugins Active plugin keys.
     571 * @return array<string, array> Snapshot sources keyed by plugin.
     572 */
     573function synoveo_export_snapshot_sources( $wpdb, array $active_plugins ) {
     574    // Step 1: Collect all required option names.
     575    $option_names = SYNOVEO_Plugin_Mappings::list_required_options( $active_plugins );
     576
     577    // Step 2: Batch fetch ALL options in ONE query.
     578    $prefetched = SYNOVEO_Option_Batch_Reader::fetch_many( $wpdb, $option_names );
     579
     580    // Step 3: Build snapshot for each plugin using prefetched data.
     581    $sources = array();
     582    foreach ( $active_plugins as $plugin_key ) {
     583        $fields = SYNOVEO_Plugin_Mappings::get_fields( $plugin_key );
     584        $data   = array();
     585
     586        foreach ( $fields as $gbp_field => $wp_path ) {
     587            $value = SYNOVEO_Plugin_Mappings::get_value_from_prefetched( $plugin_key, $gbp_field, $prefetched );
     588            // For "synoveo" native plugin, include ALL fields (even empty) so dashboard knows they're available.
     589            // For other plugins, only include fields with actual values.
     590            if ( 'synoveo' === $plugin_key ) {
     591                // Include field even if empty - dashboard needs to know synoveo can provide this field.
     592                $data[ $gbp_field ] = ( null !== $value && '' !== $value ) ? $value : null;
     593            } elseif ( null !== $value && '' !== $value ) {
     594                // Flat key: phoneNumbers.primaryPhone (not nested object).
     595                $data[ $gbp_field ] = $value;
     596            }
     597        }
     598
     599        // Step 3b: Handle special fields not in wp_options.
     600        // regularHours for Yoast SEO (stored as individual opening_hours_* options).
     601        if ( 'wordpress-seo' === $plugin_key ) {
     602            $yoast_local = isset( $prefetched['wpseo_local'] ) ? $prefetched['wpseo_local'] : null;
     603            $hours = synoveo_direct_extract_yoast_hours( $yoast_local );
     604            if ( $hours ) {
     605                $data['regularHours'] = $hours;
     606            }
     607        }
     608
     609        // regularHours for Amelia Booking (stored in amelia_settings:weekSchedule).
     610        if ( 'ameliabooking' === $plugin_key ) {
     611            $amelia_settings = isset( $prefetched['amelia_settings'] ) ? $prefetched['amelia_settings'] : null;
     612            $hours = synoveo_direct_extract_amelia_hours( $amelia_settings );
     613            if ( $hours ) {
     614                $data['regularHours'] = $hours;
     615            }
     616        }
     617
     618        // regularHours for Bookly (stored in individual bookly_bh_{day}_start/end options).
     619        if ( 'bookly-responsive-appointment-booking-tool' === $plugin_key ) {
     620            $hours = synoveo_direct_extract_bookly_hours( $prefetched );
     621            if ( $hours ) {
     622                $data['regularHours'] = $hours;
     623            }
     624        }
     625
     626        // serviceItems for booking plugins (stored in database tables).
     627        if ( 'ameliabooking' === $plugin_key ) {
     628            $services = synoveo_direct_get_amelia_services();
     629            if ( $services ) {
     630                $data['serviceItems'] = $services;
     631            }
     632        } elseif ( 'bookly-responsive-appointment-booking-tool' === $plugin_key ) {
     633            $services = synoveo_direct_get_bookly_services();
     634            if ( $services ) {
     635                $data['serviceItems'] = $services;
     636            }
     637        } elseif ( 'woocommerce' === $plugin_key ) {
     638            $services = synoveo_direct_get_woocommerce_services();
     639            if ( $services ) {
     640                $data['serviceItems'] = $services;
     641            }
     642        }
     643
     644        if ( ! empty( $data ) ) {
     645            // Step 4: Apply plugin-specific transforms (e.g., WooCommerce CC:STATE split).
     646            // Pass prefetched in case transform needs access to raw options.
     647            $data = SYNOVEO_Value_Transforms::apply( $plugin_key, $data, $prefetched );
     648
     649            // Step 5: Re-filter after transforms.
     650            // Transforms can create new keys or set values to null.
     651            // Exception: For synoveo plugin, keep null values so dashboard knows fields are available.
     652            foreach ( $data as $k => $v ) {
     653                if ( 'synoveo' === $plugin_key ) {
     654                    // Keep synoveo fields even if null - dashboard needs to know they're available.
     655                    continue;
     656                }
     657                if ( null === $v ) {
     658                    unset( $data[ $k ] );
     659                } elseif ( is_string( $v ) && '' === trim( $v ) ) {
     660                    unset( $data[ $k ] );
     661                } elseif ( is_array( $v ) && empty( $v ) ) {
     662                    unset( $data[ $k ] );
     663                }
     664            }
     665
     666            if ( ! empty( $data ) ) {
     667                $sources[ $plugin_key ] = $data;
     668            }
     669        }
     670    }
     671
     672    return $sources;
     673}
     674
     675/**
     676 * Import GBP data to WordPress options using modular mappings.
     677 * Uses batch writer for optimized DB access.
     678 * Applies reverse transforms for plugin-specific format conversion.
     679 *
     680 * @param wpdb   $wpdb        WordPress database object.
     681 * @param string $plugin_key  Target plugin to write to.
     682 * @param array  $gbp_data    GBP field => value pairs.
     683 * @return array Result with success status and details.
     684 */
     685function synoveo_import_to_plugin( $wpdb, $plugin_key, array $gbp_data ) {
     686    $results = array();
     687
     688    // Special handling for serviceItems.
     689    if ( isset( $gbp_data['serviceItems'] ) && is_array( $gbp_data['serviceItems'] ) ) {
     690        if ( 'synoveo' === $plugin_key ) {
     691            // Synoveo stores serviceItems as JSON in wp_options.
     692            // Use json_encode directly for SHORTINIT compatibility.
     693            $json_value = json_encode( $gbp_data['serviceItems'] );
     694            $option_name = 'synoveo_service_items';
     695
     696            // Check if option exists.
     697            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
     698            $exists = $wpdb->get_var(
     699                $wpdb->prepare(
     700                    "SELECT option_id FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
     701                    $option_name
     702                )
     703            );
     704
     705            if ( $exists ) {
     706                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
     707                $result = $wpdb->update(
     708                    $wpdb->options,
     709                    array( 'option_value' => $json_value ),
     710                    array( 'option_name' => $option_name ),
     711                    array( '%s' ),
     712                    array( '%s' )
     713                );
     714            } else {
     715                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
     716                $result = $wpdb->insert(
     717                    $wpdb->options,
     718                    array(
     719                        'option_name'  => $option_name,
     720                        'option_value' => $json_value,
     721                        'autoload'     => 'no',
     722                    ),
     723                    array( '%s', '%s', '%s' )
     724                );
     725            }
     726            $results['serviceItems'] = false !== $result;
     727        } else {
     728            // Booking plugins store serviceItems in database tables.
     729            $service_result = synoveo_import_service_items( $wpdb, $plugin_key, $gbp_data['serviceItems'] );
     730            $results['serviceItems'] = $service_result['success'];
     731        }
     732        unset( $gbp_data['serviceItems'] );
     733    }
     734
     735    // Only proceed with options if there's remaining data.
     736    if ( ! empty( $gbp_data ) ) {
     737        // Step 1: Apply reverse transforms (GBP → WordPress format).
     738        // e.g., for WooCommerce: regionCode + administrativeArea → CC:STATE.
     739        $transformed = SYNOVEO_Value_Transforms::reverse_apply( $plugin_key, $gbp_data );
     740
     741        // Step 2: Write to WordPress options using batch writer.
     742        $option_results = SYNOVEO_Option_Batch_Writer::write_many( $wpdb, $plugin_key, $transformed );
     743        $results = array_merge( $results, $option_results );
     744    }
     745
     746    // Step 3: Build response.
     747    $success_count = count( array_filter( $results ) );
     748    $total_count   = count( $results );
     749
     750    return array(
     751        'success'    => $success_count === $total_count,
     752        'plugin'     => $plugin_key,
     753        'fields'     => $total_count,
     754        'written'    => $success_count,
     755        'failed'     => $total_count - $success_count,
     756        'details'    => $results,
     757    );
     758}
     759
     760/**
     761 * Import serviceItems to booking plugin database tables.
     762 * Matches services by name and updates price only (doesn't create new services).
     763 * Uses modular mappings for table configuration (single source of truth).
     764 *
     765 * @param wpdb   $wpdb         WordPress database object.
     766 * @param string $plugin_key   Plugin identifier (ameliabooking, bookly-responsive-appointment-booking-tool).
     767 * @param array  $service_items GBP serviceItems array.
     768 * @return array Result with success status.
     769 */
     770function synoveo_import_service_items( $wpdb, $plugin_key, array $service_items ) {
     771    $updated = 0;
     772    $failed  = 0;
     773
     774    // Get table configuration from modular mappings (single source of truth).
     775    $mapping_config = SYNOVEO_Plugin_Mappings::get_table_config( $plugin_key, 'serviceItems' );
     776
     777    if ( ! $mapping_config ) {
     778        return array(
     779            'success' => false,
     780            'error'   => 'No serviceItems table mapping found for plugin: ' . $plugin_key,
     781            'updated' => 0,
     782            'failed'  => count( $service_items ),
     783        );
     784    }
     785
     786    // Build table config with prefixed table name.
     787    $table_config = array(
     788        'table'      => $wpdb->prefix . $mapping_config['table'],
     789        'name_col'   => $mapping_config['name_col'],
     790        'price_col'  => $mapping_config['price_col'],
     791        'status_col' => $mapping_config['status_col'],
     792        'status_val' => $mapping_config['status_val'],
     793    );
     794
     795    // Check if table exists.
     796    $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_config['table'] ) );
     797    if ( $table_exists !== $table_config['table'] ) {
     798        return array(
     799            'success' => false,
     800            'error'   => 'Table not found: ' . $table_config['table'],
     801            'updated' => 0,
     802            'failed'  => count( $service_items ),
     803        );
     804    }
     805
     806    // Process each service item.
     807    foreach ( $service_items as $item ) {
     808        // Extract service name and price from GBP format.
     809        $name  = null;
     810        $price = null;
     811
     812        if ( isset( $item['freeFormServiceItem']['label']['displayName'] ) ) {
     813            $name = $item['freeFormServiceItem']['label']['displayName'];
     814        }
     815        if ( isset( $item['price'] ) ) {
     816            $units = isset( $item['price']['units'] ) ? (int) $item['price']['units'] : 0;
     817            $nanos = isset( $item['price']['nanos'] ) ? (int) $item['price']['nanos'] : 0;
     818            $price = $units + ( $nanos / 1000000000 );
     819        }
     820
     821        if ( ! $name || null === $price ) {
     822            ++$failed;
     823            continue;
     824        }
     825
     826        // Find existing service by name (case-insensitive match).
     827        // Note: Table/column names come from our own config (not user input), so safe to interpolate.
     828        // Using backticks directly because %i placeholder may not work in SHORTINIT mode.
     829        $table      = $table_config['table'];
     830        $name_col   = $table_config['name_col'];
     831        $status_col = $table_config['status_col'];
     832        $status_val = $table_config['status_val'];
     833
     834        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     835        $existing = $wpdb->get_row(
     836            $wpdb->prepare(
     837                "SELECT id FROM `{$table}` WHERE LOWER(`{$name_col}`) = LOWER(%s) AND `{$status_col}` = %s LIMIT 1",
     838                $name,
     839                $status_val
     840            ),
     841            ARRAY_A
     842        );
     843
     844        if ( ! $existing ) {
     845            // Service not found - create new service.
     846            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
     847            $result = $wpdb->insert(
     848                $table_config['table'],
     849                array(
     850                    $table_config['name_col']   => $name,
     851                    $table_config['price_col']  => $price,
     852                    $table_config['status_col'] => $status_val,
     853                ),
     854                array( '%s', '%f', '%s' )
     855            );
     856
     857            if ( false !== $result ) {
     858                ++$updated;
     859            } else {
     860                ++$failed;
     861            }
     862        } else {
     863            // Service exists - update price.
     864            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
     865            $result = $wpdb->update(
     866                $table_config['table'],
     867                array( $table_config['price_col'] => $price ),
     868                array( 'id' => $existing['id'] ),
     869                array( '%f' ),
     870                array( '%d' )
     871            );
     872
     873            if ( false !== $result ) {
     874                ++$updated;
     875            } else {
     876                ++$failed;
     877            }
     878        }
     879    }
     880
     881    return array(
     882        'success' => $failed === 0,
     883        'updated' => $updated,
     884        'failed'  => $failed,
     885    );
     886}
     887
     888/**
     889 * Handle import request - write GBP data to multiple WordPress plugins.
     890 *
     891 * @return array Import results.
     892 */
     893function synoveo_handle_import() {
     894    global $wpdb;
     895
     896    // Get JSON body.
     897    $input = file_get_contents( 'php://input' );
     898    if ( empty( $input ) ) {
     899        return array(
     900            'success' => false,
     901            'error'   => 'Empty request body',
     902        );
     903    }
     904
     905    $data = json_decode( $input, true );
     906    if ( JSON_ERROR_NONE !== json_last_error() ) {
     907        return array(
     908            'success' => false,
     909            'error'   => 'Invalid JSON: ' . json_last_error_msg(),
     910        );
     911    }
     912
     913    // Validate structure: { "sources": { "plugin-key": { "gbp.field": value } } }
     914    if ( ! isset( $data['sources'] ) || ! is_array( $data['sources'] ) ) {
     915        return array(
     916            'success' => false,
     917            'error'   => 'Missing or invalid "sources" key. Expected: { "sources": { "plugin-key": { "field": value } } }',
     918        );
     919    }
     920
     921    $results        = array();
     922    $total_success  = 0;
     923    $total_failed   = 0;
     924
     925    foreach ( $data['sources'] as $plugin_key => $gbp_data ) {
     926        if ( ! is_array( $gbp_data ) ) {
     927            $results[ $plugin_key ] = array(
     928                'success' => false,
     929                'error'   => 'Invalid data format for plugin',
     930            );
     931            ++$total_failed;
     932            continue;
     933        }
     934
     935        // Check if plugin has mapping.
     936        $fields = SYNOVEO_Plugin_Mappings::get_fields( $plugin_key );
     937        if ( empty( $fields ) ) {
     938            $results[ $plugin_key ] = array(
     939                'success' => false,
     940                'error'   => 'No mapping found for plugin: ' . $plugin_key,
     941            );
     942            ++$total_failed;
     943            continue;
     944        }
     945
     946        // Import to plugin.
     947        $result = synoveo_import_to_plugin( $wpdb, $plugin_key, $gbp_data );
     948        $results[ $plugin_key ] = $result;
     949
     950        if ( $result['success'] ) {
     951            ++$total_success;
     952        } else {
     953            ++$total_failed;
     954        }
     955    }
     956
     957    return array(
     958        'success'        => 0 === $total_failed,
     959        'plugins_total'  => count( $data['sources'] ),
     960        'plugins_ok'     => $total_success,
     961        'plugins_failed' => $total_failed,
     962        'results'        => $results,
     963    );
     964}
     965
     966/**
     967 * Generate debug information for troubleshooting.
     968 *
     969 * @return array Debug information.
     970 */
     971function synoveo_get_debug_info() {
     972    global $wpdb;
     973
     974    // Get all registered mappings.
     975    $all_mappings   = SYNOVEO_Plugin_Mappings::load_all();
     976    $active_plugins = synoveo_get_active_plugin_keys();
     977
     978    // Build plugin info.
     979    $plugins_info = array();
     980    foreach ( $all_mappings as $key => $mapping ) {
     981        $is_active = in_array( $key, $active_plugins, true );
     982        $fields    = isset( $mapping['fields'] ) ? $mapping['fields'] : array();
     983
     984        $plugins_info[ $key ] = array(
     985            'display_name'    => $mapping['display_name'] ?? $key,
     986            'plugin_file'     => $mapping['plugin_file'] ?? null,
     987            'active'          => $is_active,
     988            'fields_count'    => count( $fields ),
     989            'mapped_fields'   => array_keys( $fields ),
     990        );
     991    }
     992
     993    // Get WordPress install info.
     994    $wp_info = array(
     995        'table_prefix' => $wpdb->prefix,
     996        'options_table' => $wpdb->options,
     997    );
     998
     999    // List all option names used by mappings.
     1000    $required_options = SYNOVEO_Plugin_Mappings::list_required_options( array_keys( $all_mappings ) );
     1001
     1002    // Check which options exist.
     1003    $existing_options = array();
     1004    if ( ! empty( $required_options ) ) {
     1005        $prefetched = SYNOVEO_Option_Batch_Reader::fetch_many( $wpdb, $required_options );
     1006        $existing_options = array_keys( $prefetched );
     1007    }
     1008
     1009    return array(
     1010        'version'           => '2.0',
     1011        'php_version'       => PHP_VERSION,
     1012        'wordpress'         => $wp_info,
     1013        'mappings_loaded'   => count( $all_mappings ),
     1014        'active_plugins'    => $active_plugins,
     1015        'plugins'           => $plugins_info,
     1016        'required_options'  => $required_options,
     1017        'existing_options'  => $existing_options,
     1018        'missing_options'   => array_diff( $required_options, $existing_options ),
     1019    );
     1020}
     1021
     1022/**
     1023 * Extract all business data from wp_options using modular mappings.
     1024 * Uses batch reader for optimized DB access (1 query vs N queries).
     1025 *
     1026 * IMPORTANT: Only extracts data from ACTIVE plugins to avoid orphaned data confusion.
     1027 *
     1028 * Version 2.0: Uses modular plugin mappings with flat GBP field keys.
     1029 * - Add new plugin = add mapping file (no code changes)
     1030 * - Transform layer handles plugin-specific format normalization
     1031 * - Batch reader minimizes DB queries
    4111032 */
    4121033function synoveo_direct_extract_snapshot() {
    4131034    global $wpdb;
    4141035
    415     // Get only active plugins - this prevents showing orphaned data from deactivated plugins.
    416     $active_plugins = synoveo_get_active_supported_plugins();
    417 
    418     // Debug: check if API credentials are stored.
    419     $stored_client_id = synoveo_direct_get_option( 'synoveo_client_id', '' );
    420     $has_credentials  = ! empty( $stored_client_id );
    421 
    4221036    $snapshot = array(
    423         'sources'         => array(),
    424         'extracted_at'    => gmdate( 'c' ),
    425         'active_plugins'  => $active_plugins,
    426         'has_credentials' => $has_credentials,
    427         'client_id_prefix' => $has_credentials ? substr( $stored_client_id, 0, 20 ) . '...' : null,
    428     );
    429 
    430     // WordPress Core - always included.
    431     $snapshot['sources']['wordpress-core'] = array(
    432         'title'       => synoveo_get_mapped_value( 'wordpress-core', 'title' ),
    433         'profile'     => array(
    434             'description' => synoveo_get_mapped_value( 'wordpress-core', 'profile.description' ),
    435         ),
    436         'websiteUri'  => synoveo_get_mapped_value( 'wordpress-core', 'websiteUri' ),
    437         'admin_email' => synoveo_get_mapped_value( 'wordpress-core', 'email' ),
    438     );
    439 
    440     // WooCommerce - only if active.
    441     if ( synoveo_direct_is_plugin_active( 'woocommerce' ) ) {
    442         $wc_address = synoveo_get_mapped_value( 'woocommerce', 'addressLines' );
    443         if ( $wc_address ) {
    444             // WooCommerce stores country:state together (e.g., "US:CA"), needs parsing.
    445             $country_state = synoveo_get_mapped_value( 'woocommerce', 'regionCode' ) ?? '';
    446             $country_code  = '';
    447             $state         = '';
    448             if ( strpos( $country_state, ':' ) !== false ) {
    449                 list($country_code, $state) = explode( ':', $country_state );
    450             } else {
    451                 $country_code = $country_state;
    452             }
    453 
    454             // Build addressLines array.
    455             $address_line_2 = synoveo_direct_get_option( 'woocommerce_store_address_2', '' );
    456 
    457             $wc_source = array(
    458                 'title'             => synoveo_get_mapped_value( 'woocommerce', 'title' ),
    459                 'storefrontAddress' => array(
    460                     'addressLines'       => array_filter( array( $wc_address, $address_line_2 ) ),
    461                     'locality'           => synoveo_get_mapped_value( 'woocommerce', 'locality' ),
    462                     'administrativeArea' => $state,
    463                     'postalCode'         => synoveo_get_mapped_value( 'woocommerce', 'postalCode' ),
    464                     'regionCode'         => $country_code,
    465                 ),
    466                 'phoneNumbers'      => array(
    467                     'primaryPhone' => synoveo_get_mapped_value( 'woocommerce', 'primaryPhone' ),
    468                 ),
    469                 'profile'           => array(
    470                     'description' => synoveo_get_mapped_value( 'woocommerce', 'profile.description' ),
    471                 ),
    472                 'websiteUri'        => synoveo_get_mapped_value( 'woocommerce', 'websiteUri' ),
    473             );
    474 
    475             // Add products/services from database - GBP API field: serviceItems.
    476             $wc_services = synoveo_direct_get_woocommerce_services();
    477             if ( $wc_services ) {
    478                 $wc_source['serviceItems'] = $wc_services;
    479             }
    480 
    481             // Add shop action links.
    482             $shop_page_id = synoveo_direct_get_option( 'woocommerce_shop_page_id', 0 );
    483             $home_url     = rtrim( synoveo_direct_get_option( 'home', '' ), '/' );
    484 
    485             if ( $shop_page_id ) {
    486                 $shop_slug = $wpdb->get_var(
    487                     $wpdb->prepare(
    488                         "SELECT post_name FROM {$wpdb->posts} WHERE ID = %d AND post_status = 'publish'",
    489                         $shop_page_id
    490                     )
    491                 );
    492                 $shop_url  = $shop_slug ? "{$home_url}/{$shop_slug}/" : "{$home_url}/shop/";
    493             } else {
    494                 $shop_url = "{$home_url}/shop/";
    495             }
    496 
    497             $wc_source['actionLinks'] = array(
    498                 'shop'         => $shop_url,
    499                 'foodOrdering' => $shop_url,
    500                 'foodDelivery' => $shop_url,
    501                 'foodTakeout'  => $shop_url,
    502             );
    503 
    504             // NOTE: WooCommerce does not have GBP-compliant business categories.
    505             // WooCommerce product categories (product_cat taxonomy) are NOT the same as.
    506             // GBP business categories (predefined by Google: "Café", "Restaurant", etc.).
    507             // Categories can only be synced GBP → WordPress, not WordPress → GBP.
    508             // See: docs/wordpress/singlePluginSource.md.
    509 
    510             $snapshot['sources']['woocommerce'] = $wc_source;
    511         }
    512     }
    513 
    514     // Yoast SEO - only if active.
    515     if ( synoveo_direct_is_plugin_active( 'wordpress-seo' ) ) {
    516         $yoast_social = synoveo_direct_get_option( 'wpseo_social', array() );
    517         $yoast_local  = synoveo_direct_get_option( 'wpseo_local', array() );
    518         if ( ! empty( $yoast_social ) || ! empty( $yoast_local ) ) {
    519             $address_line_1 = synoveo_get_mapped_value( 'wordpress-seo', 'addressLines' );
    520             $address_line_2 = $yoast_local['location_address_2'] ?? '';
    521 
    522             $snapshot['sources']['wordpress-seo'] = array(
    523                 'title'             => synoveo_get_mapped_value( 'wordpress-seo', 'title' ),
    524                 'storefrontAddress' => array(
    525                     'addressLines'       => array_filter( array( $address_line_1, $address_line_2 ) ),
    526                     'locality'           => synoveo_get_mapped_value( 'wordpress-seo', 'locality' ),
    527                     'administrativeArea' => synoveo_get_mapped_value( 'wordpress-seo', 'administrativeArea' ),
    528                     'postalCode'         => synoveo_get_mapped_value( 'wordpress-seo', 'postalCode' ),
    529                     'regionCode'         => synoveo_get_mapped_value( 'wordpress-seo', 'regionCode' ),
    530                 ),
    531                 'phoneNumbers'      => array(
    532                     'primaryPhone' => synoveo_get_mapped_value( 'wordpress-seo', 'primaryPhone' ),
    533                 ),
    534                 'profile'           => array(
    535                     'description' => synoveo_get_mapped_value( 'wordpress-seo', 'profile.description' ),
    536                 ),
    537                 'websiteUri'        => synoveo_get_mapped_value( 'wordpress-seo', 'websiteUri' ),
    538                 'socialProfiles'    => array_filter(
    539                     array(
    540                         'facebook'  => synoveo_get_mapped_value( 'wordpress-seo', 'profile.socialMedia.facebook' ),
    541                         'twitter'   => synoveo_get_mapped_value( 'wordpress-seo', 'profile.socialMedia.twitter' ),
    542                         'instagram' => synoveo_get_mapped_value( 'wordpress-seo', 'profile.socialMedia.instagram' ),
    543                         'linkedin'  => synoveo_get_mapped_value( 'wordpress-seo', 'profile.socialMedia.linkedin' ),
    544                         'youtube'   => synoveo_get_mapped_value( 'wordpress-seo', 'profile.socialMedia.youtube' ),
    545                         'pinterest' => synoveo_get_mapped_value( 'wordpress-seo', 'profile.socialMedia.pinterest' ),
    546                     )
    547                 ),
    548                 'categories'        => array(
    549                     'primaryCategory' => synoveo_get_mapped_value( 'wordpress-seo', 'primaryCategory' ),
    550                 ),
    551             );
    552 
    553             $yoast_hours = synoveo_direct_extract_yoast_hours( $yoast_local );
    554             if ( ! empty( $yoast_hours ) ) {
    555                 $snapshot['sources']['wordpress-seo']['regularHours'] = $yoast_hours;
    556             }
    557         }
    558     }
    559 
    560     // RankMath SEO - only if active.
    561     if ( synoveo_direct_is_plugin_active( 'seo-by-rank-math' ) ) {
    562         $rankmath = synoveo_direct_get_option( 'rank-math-options-titles', array() );
    563         if ( ! empty( $rankmath ) ) {
    564             $local_address = $rankmath['local_address'] ?? array();
    565             $phone_numbers = $rankmath['phone_numbers'] ?? array();
    566             $primary_phone = '';
    567             if ( ! empty( $phone_numbers ) && is_array( $phone_numbers ) && isset( $phone_numbers[0]['number'] ) ) {
    568                 $primary_phone = $phone_numbers[0]['number'];
    569             }
    570 
    571             $snapshot['sources']['seo-by-rank-math'] = array(
    572                 'title'             => $rankmath['knowledgegraph_name'] ?? $rankmath['website_name'] ?? '',
    573                 'storefrontAddress' => array(
    574                     'addressLines'       => array_filter( array( $local_address['streetAddress'] ?? '' ) ),
    575                     'locality'           => $local_address['addressLocality'] ?? '',
    576                     'administrativeArea' => $local_address['addressRegion'] ?? '',
    577                     'postalCode'         => $local_address['postalCode'] ?? '',
    578                     'regionCode'         => $local_address['addressCountry'] ?? '',
    579                 ),
    580                 'phoneNumbers'      => array( 'primaryPhone' => $primary_phone ),
    581                 'profile'           => array( 'description' => $rankmath['organization_description'] ?? '' ),
    582                 'websiteUri'        => $rankmath['url'] ?? '',
    583                 'socialProfiles'    => array_filter(
    584                     array(
    585                         'facebook' => $rankmath['social_url_facebook'] ?? '',
    586                         'twitter'  => $rankmath['twitter_author_names'] ?? '',
    587                     )
    588                 ),
    589                 'categories'        => array( 'primaryCategory' => $rankmath['local_business_type'] ?? '' ),
    590             );
    591 
    592             $rm_opening_hours = $rankmath['opening_hours'] ?? array();
    593             if ( ! empty( $rm_opening_hours ) && is_array( $rm_opening_hours ) ) {
    594                 $periods = array();
    595                 $day_map = array(
    596                     'monday'    => 'MONDAY',
    597                     'tuesday'   => 'TUESDAY',
    598                     'wednesday' => 'WEDNESDAY',
    599                     'thursday'  => 'THURSDAY',
    600                     'friday'    => 'FRIDAY',
    601                     'saturday'  => 'SATURDAY',
    602                     'sunday'    => 'SUNDAY',
    603                 );
    604 
    605                 foreach ( $rm_opening_hours as $schedule ) {
    606                     $day  = strtolower( $schedule['day'] ?? '' );
    607                     $time = $schedule['time'] ?? '';
    608 
    609                     if ( empty( $day ) || empty( $time ) || ! isset( $day_map[ $day ] ) ) {
    610                         continue;
    611                     }
    612 
    613                     if ( preg_match( '/(\d{1,2}:\d{2}(?:\s*[AP]M)?)\s*[-–]\s*(\d{1,2}:\d{2}(?:\s*[AP]M)?)/i', $time, $matches ) ) {
    614                         $open_time  = synoveo_time_to_gbp_object( trim( $matches[1] ) );
    615                         $close_time = synoveo_time_to_gbp_object( trim( $matches[2] ) );
    616 
    617                         if ( $open_time && $close_time ) {
    618                             $periods[] = array(
    619                                 'openDay'   => $day_map[ $day ],
    620                                 'openTime'  => $open_time,
    621                                 'closeDay'  => $day_map[ $day ],
    622                                 'closeTime' => $close_time,
    623                             );
    624                         }
    625                     }
    626                 }
    627 
    628                 if ( ! empty( $periods ) ) {
    629                     $snapshot['sources']['seo-by-rank-math']['regularHours'] = array( 'periods' => $periods );
    630                 }
    631             }
    632         }
    633     }
    634 
    635     // Business Profile plugin - only if active.
    636     if ( synoveo_direct_is_plugin_active( 'business-profile' ) ) {
    637         $bpfwp = synoveo_direct_get_option( 'bpfwp-settings', array() );
    638         if ( ! empty( $bpfwp ) ) {
    639             $snapshot['sources']['business-profile'] = array(
    640                 'title'             => $bpfwp['name'] ?? '',
    641                 'storefrontAddress' => array(
    642                     'addressLines' => array_filter( array( $bpfwp['address']['text'] ?? '' ) ),
    643                 ),
    644                 'phoneNumbers'      => array( 'primaryPhone' => $bpfwp['phone'] ?? '' ),
    645                 'websiteUri'        => $bpfwp['website'] ?? '',
    646             );
    647         }
    648     }
    649 
    650     // Amelia Booking - only if active.
    651     if ( synoveo_direct_is_plugin_active( 'ameliabooking' ) ) {
    652         $amelia = synoveo_direct_get_option( 'amelia_settings', '' );
    653         if ( ! empty( $amelia ) ) {
    654             $amelia_data = is_string( $amelia ) ? json_decode( $amelia, true ) : $amelia;
    655             if ( $amelia_data ) {
    656                 $amelia_source = array(
    657                     'title'             => $amelia_data['company']['name'] ?? '',
    658                     'storefrontAddress' => array(
    659                         'addressLines' => array_filter( array( $amelia_data['company']['address'] ?? '' ) ),
    660                     ),
    661                     'phoneNumbers'      => array( 'primaryPhone' => $amelia_data['company']['phone'] ?? '' ),
    662                     'websiteUri'        => $amelia_data['company']['website'] ?? '',
    663                 );
    664 
    665                 // Extract business hours from weekSchedule.
    666                 if ( ! empty( $amelia_data['weekSchedule'] ) && is_array( $amelia_data['weekSchedule'] ) ) {
    667                     $periods = array();
    668                     foreach ( $amelia_data['weekSchedule'] as $schedule ) {
    669                         $day  = strtoupper( $schedule['day'] ?? '' );
    670                         $time = $schedule['time'] ?? array();
    671 
    672                         if ( empty( $time ) || count( $time ) < 2 ) {
    673                             continue;
    674                         }
    675 
    676                         $periods[] = array(
    677                             'openDay'   => $day,
    678                             'openTime'  => synoveo_time_to_gbp_object( $time[0] ),
    679                             'closeDay'  => $day,
    680                             'closeTime' => synoveo_time_to_gbp_object( $time[1] ),
    681                         );
    682                     }
    683 
    684                     if ( ! empty( $periods ) ) {
    685                         $amelia_source['regularHours'] = array( 'periods' => $periods );
    686                     }
    687                 }
    688 
    689                 $amelia_services = synoveo_direct_get_amelia_services();
    690                 if ( $amelia_services ) {
    691                     $amelia_source['serviceItems'] = $amelia_services;
    692                 }
    693 
    694                 $home_url     = rtrim( synoveo_direct_get_option( 'home', '' ), '/' );
    695                 $booking_page = $wpdb->get_row(
    696                     "SELECT ID, post_name FROM {$wpdb->posts} WHERE post_content LIKE '%[ameliabooking%' AND post_status = 'publish' LIMIT 1"
    697                 );
    698                 if ( $booking_page && ! empty( $booking_page->post_name ) ) {
    699                     $amelia_source['actionLinks'] = array( 'booking' => "{$home_url}/{$booking_page->post_name}/" );
    700                 }
    701 
    702                 $snapshot['sources']['ameliabooking'] = $amelia_source;
    703             }
    704         }
    705     }
    706 
    707     // AIOSEO (All in One SEO) - only if active.
    708     if ( synoveo_direct_is_plugin_active( 'all-in-one-seo-pack' ) ) {
    709         $aioseo = synoveo_direct_get_option( 'aioseo_options', '' );
    710         if ( ! empty( $aioseo ) ) {
    711             $aioseo_source = array(
    712                 'title'             => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'title' ),
    713                 'storefrontAddress' => array(
    714                     'addressLines'       => array_filter( array( synoveo_get_mapped_value( 'all-in-one-seo-pack', 'addressLines' ) ) ),
    715                     'locality'           => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'locality' ),
    716                     'administrativeArea' => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'administrativeArea' ),
    717                     'postalCode'         => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'postalCode' ),
    718                     'regionCode'         => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'regionCode' ),
    719                 ),
    720                 'phoneNumbers'      => array( 'primaryPhone' => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'primaryPhone' ) ),
    721                 'profile'           => array( 'description' => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'profile.description' ) ),
    722                 'websiteUri'        => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'websiteUri' ),
    723                 'socialProfiles'    => array_filter(
    724                     array(
    725                         'facebook'  => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'profile.socialMedia.facebook' ),
    726                         'twitter'   => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'profile.socialMedia.twitter' ),
    727                         'instagram' => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'profile.socialMedia.instagram' ),
    728                         'linkedin'  => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'profile.socialMedia.linkedin' ),
    729                         'youtube'   => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'profile.socialMedia.youtube' ),
    730                         'pinterest' => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'profile.socialMedia.pinterest' ),
    731                         'tiktok'    => synoveo_get_mapped_value( 'all-in-one-seo-pack', 'profile.socialMedia.tiktok' ),
    732                     )
    733                 ),
    734             );
    735 
    736             $has_data = ! empty( $aioseo_source['title'] ) || ! empty( $aioseo_source['phoneNumbers']['primaryPhone'] ) || ! empty( $aioseo_source['socialProfiles'] );
    737             if ( $has_data ) {
    738                 $snapshot['sources']['all-in-one-seo-pack'] = $aioseo_source;
    739             }
    740         }
    741     }
    742 
    743     // Bookly Responsive Appointment Booking - only if active.
    744     if ( synoveo_direct_is_plugin_active( 'bookly-responsive-appointment-booking-tool' ) ) {
    745         $bookly_monday_start = synoveo_direct_get_option( 'bookly_bh_monday_start', '' );
    746         if ( ! empty( $bookly_monday_start ) ) {
    747             $days    = array( 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday' );
    748             $periods = array();
    749 
    750             foreach ( $days as $day ) {
    751                 $start_time = synoveo_direct_get_option( "bookly_bh_{$day}_start", '' );
    752                 $end_time   = synoveo_direct_get_option( "bookly_bh_{$day}_end", '' );
    753 
    754                 if ( ! empty( $start_time ) && ! empty( $end_time ) ) {
    755                     $start_time = substr( $start_time, 0, 5 );
    756                     $end_time   = substr( $end_time, 0, 5 );
    757 
    758                     $periods[] = array(
    759                         'openDay'   => strtoupper( $day ),
    760                         'openTime'  => synoveo_time_to_gbp_object( $start_time ),
    761                         'closeDay'  => strtoupper( $day ),
    762                         'closeTime' => synoveo_time_to_gbp_object( $end_time ),
    763                     );
    764                 }
    765             }
    766 
    767             $bookly_source = array(
    768                 'regularHours' => ! empty( $periods ) ? array( 'periods' => $periods ) : null,
    769                 'currencyCode' => synoveo_direct_get_option( 'bookly_pmt_currency', 'USD' ),
    770             );
    771 
    772             $bookly_services = synoveo_direct_get_bookly_services();
    773             if ( $bookly_services ) {
    774                 $bookly_source['serviceItems'] = $bookly_services;
    775             }
    776 
    777             $home_url     = rtrim( synoveo_direct_get_option( 'home', '' ), '/' );
    778             $booking_page = $wpdb->get_row(
    779                 "SELECT ID, post_name FROM {$wpdb->posts} WHERE post_content LIKE '%[bookly-form%' AND post_status = 'publish' LIMIT 1"
    780             );
    781             if ( $booking_page && ! empty( $booking_page->post_name ) ) {
    782                 $bookly_source['actionLinks'] = array( 'booking' => "{$home_url}/{$booking_page->post_name}/" );
    783             }
    784 
    785             $snapshot['sources']['bookly-responsive-appointment-booking-tool'] = $bookly_source;
    786         }
    787     }
    788 
    789     // Five Star Restaurant Reservations - only if active.
    790     if ( synoveo_direct_is_plugin_active( 'five-star-restaurant-reservations' ) ) {
    791         $rtb_settings = synoveo_direct_get_option( 'rtb-settings', array() );
    792         if ( ! empty( $rtb_settings ) && is_array( $rtb_settings ) ) {
    793             $rtb_data = array(
    794                 'title'        => $rtb_settings['business-name'] ?? '',
    795                 'phoneNumbers' => array( 'primaryPhone' => $rtb_settings['reply-to-phone'] ?? '' ),
    796             );
    797 
    798             if ( isset( $rtb_settings['schedule-open'] ) && isset( $rtb_settings['schedule-closed'] ) ) {
    799                 $days    = array( 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday' );
    800                 $periods = array();
    801 
    802                 foreach ( $days as $index => $day ) {
    803                     $open_times  = $rtb_settings['schedule-open'][ $index ] ?? array();
    804                     $close_times = $rtb_settings['schedule-closed'][ $index ] ?? array();
    805 
    806                     if ( ! empty( $open_times ) && ! empty( $close_times ) ) {
    807                         foreach ( $open_times as $slot_index => $open_time ) {
    808                             $close_time = $close_times[ $slot_index ] ?? '';
    809                             if ( ! empty( $open_time ) && ! empty( $close_time ) ) {
    810                                 $periods[] = array(
    811                                     'openDay'   => strtoupper( $day ),
    812                                     'openTime'  => synoveo_time_to_gbp_object( $open_time ),
    813                                     'closeDay'  => strtoupper( $day ),
    814                                     'closeTime' => synoveo_time_to_gbp_object( $close_time ),
    815                                 );
    816                             }
    817                         }
    818                     }
    819                 }
    820 
    821                 if ( ! empty( $periods ) ) {
    822                     $rtb_data['regularHours'] = array( 'periods' => $periods );
    823                 }
    824             }
    825 
    826             if ( ! empty( $rtb_data['title'] ) || ! empty( $rtb_data['phoneNumbers']['primaryPhone'] ) || isset( $rtb_data['regularHours'] ) ) {
    827                 $snapshot['sources']['five-star-restaurant-reservations'] = $rtb_data;
    828             }
    829         }
    830     }
    831 
    832     // MotoPress Hotel Booking (Lodging) - only if active.
    833     if ( synoveo_direct_is_plugin_active( 'motopress-hotel-booking-lite' ) ) {
    834         $mphb_settings = synoveo_direct_get_option( 'mphb_settings', array() );
    835         if ( ! empty( $mphb_settings ) && is_array( $mphb_settings ) ) {
    836             $lodging_data = array();
    837 
    838             if ( isset( $mphb_settings['general'] ) ) {
    839                 $lodging_data['title'] = $mphb_settings['general']['business_name'] ?? '';
    840             }
    841 
    842             if ( isset( $mphb_settings['currency'] ) ) {
    843                 $lodging_data['currencyCode'] = $mphb_settings['currency']['currency_symbol'] ?? '';
    844             }
    845 
    846             if ( isset( $mphb_settings['booking'] ) ) {
    847                 $lodging_data['lodging'] = array(
    848                     'policies' => array(
    849                         'checkInTime'  => $mphb_settings['booking']['check_in_time'] ?? '',
    850                         'checkOutTime' => $mphb_settings['booking']['check_out_time'] ?? '',
    851                     ),
    852                 );
    853             }
    854 
    855             if ( ! empty( $lodging_data['title'] ) || isset( $lodging_data['lodging'] ) ) {
    856                 $snapshot['sources']['motopress-hotel-booking-lite'] = $lodging_data;
    857             }
    858         }
    859     }
    860 
    861     // Five Star Restaurant Menu (fdm plugin) - only if active.
    862     if ( synoveo_direct_is_plugin_active( 'food-and-drink-menu' ) ) {
    863         $fdm_settings = synoveo_direct_get_option( 'fdm-settings', array() );
    864         if ( ! empty( $fdm_settings ) && is_array( $fdm_settings ) ) {
    865             $menu_data = array();
    866 
    867             if ( isset( $fdm_settings['business-name'] ) ) {
    868                 $menu_data['title'] = $fdm_settings['business-name'];
    869             }
    870 
    871             if ( isset( $fdm_settings['currency-symbol'] ) ) {
    872                 $menu_data['currencyCode'] = $fdm_settings['currency-symbol'];
    873             }
    874 
    875             if ( ! empty( $menu_data ) ) {
    876                 $snapshot['sources']['food-and-drink-menu'] = $menu_data;
    877             }
    878         }
    879     }
     1037        'version'   => '2.0',
     1038        'timestamp' => gmdate( 'c' ),
     1039        'sources'   => array(),
     1040    );
     1041
     1042    // Dynamically detect active plugins from registry.
     1043    // No hardcoding - add new plugin = add mapping file.
     1044    $active_plugins = synoveo_get_active_plugin_keys();
     1045
     1046    // Export using modular mappings with batch DB read + transforms.
     1047    $snapshot['sources'] = synoveo_export_snapshot_sources( $wpdb, $active_plugins );
    8801048
    8811049    return $snapshot;
     
    9221090
    9231091    // Get visible services.
    924     // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SHORTINIT mode requires direct queries.
     1092    // Note: Table name comes from our own config (not user input), so safe to interpolate.
     1093    // Using backticks directly because %i placeholder may not work in SHORTINIT mode.
     1094    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SHORTINIT mode requires direct queries.
    9251095    $services = $wpdb->get_results(
    9261096        $wpdb->prepare(
    927             'SELECT id, name, description, price, duration FROM %i WHERE status = %s ORDER BY position ASC LIMIT 50',
    928             $table,
     1097            "SELECT id, name, description, price, duration FROM `{$table}` WHERE status = %s ORDER BY position ASC LIMIT 50",
    9291098            'visible'
    9301099        ),
     
    9911160
    9921161    // Get public services.
    993     // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SHORTINIT mode requires direct queries.
     1162    // Note: Table name comes from our own config (not user input), so safe to interpolate.
     1163    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SHORTINIT mode requires direct queries.
    9941164    $services = $wpdb->get_results(
    9951165        $wpdb->prepare(
    996             'SELECT id, title, info, price, duration FROM %i WHERE visibility = %s ORDER BY position ASC LIMIT 50',
    997             $table,
     1166            "SELECT id, title, info, price, duration FROM `{$table}` WHERE visibility = %s ORDER BY position ASC LIMIT 50",
    9981167            'public'
    9991168        ),
     
    10481217
    10491218    // Get products (limit to 50 for performance).
     1219    // Note: Table names come from wpdb properties (WordPress core), so safe to use.
    10501220    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SHORTINIT mode requires direct queries.
    1051     $products = $wpdb->get_results(
    1052         $wpdb->prepare(
    1053             "SELECT p.ID, p.post_title, p.post_excerpt, pm.meta_value as price
    1054             FROM %i p
    1055             LEFT JOIN %i pm ON p.ID = pm.post_id AND pm.meta_key = '_regular_price'
    1056             WHERE p.post_type = 'product' AND p.post_status = 'publish'
    1057             ORDER BY p.menu_order ASC, p.post_date DESC
    1058             LIMIT 50",
    1059             $wpdb->posts,
    1060             $wpdb->postmeta
    1061         ),
     1221    $posts_table    = $wpdb->posts;
     1222    $postmeta_table = $wpdb->postmeta;
     1223    $products       = $wpdb->get_results(
     1224        "SELECT p.ID, p.post_title, p.post_excerpt, pm.meta_value as price
     1225        FROM `{$posts_table}` p
     1226        LEFT JOIN `{$postmeta_table}` pm ON p.ID = pm.post_id AND pm.meta_key = '_regular_price'
     1227        WHERE p.post_type = 'product' AND p.post_status = 'publish'
     1228        ORDER BY p.menu_order ASC, p.post_date DESC
     1229        LIMIT 50",
    10621230        ARRAY_A
    10631231    );
     
    12121380    $path = $field_paths[ $field_name ] ?? $field_name;
    12131381
    1214     // Navigate nested path.
    1215     $value = $source;
    1216     foreach ( explode( '.', $path ) as $key ) {
    1217         if ( is_array( $value ) && isset( $value[ $key ] ) ) {
    1218             $value = $value[ $key ];
    1219         } else {
    1220             return array(
    1221                 'found'  => false,
    1222                 'value'  => null,
    1223                 'reason' => 'field_not_found',
    1224             );
     1382    // First, try direct key lookup (snapshot uses flat keys like "phoneNumbers.primaryPhone").
     1383    if ( isset( $source[ $path ] ) ) {
     1384        $value = $source[ $path ];
     1385    } elseif ( isset( $source[ $field_name ] ) ) {
     1386        // Try original field name if path mapping didn't match.
     1387        $value = $source[ $field_name ];
     1388    } else {
     1389        // Fallback: Navigate nested path for backwards compatibility.
     1390        $value = $source;
     1391        foreach ( explode( '.', $path ) as $key ) {
     1392            if ( is_array( $value ) && isset( $value[ $key ] ) ) {
     1393                $value = $value[ $key ];
     1394            } else {
     1395                return array(
     1396                    'found'  => false,
     1397                    'value'  => null,
     1398                    'reason' => 'field_not_found',
     1399                );
     1400            }
    12251401        }
    12261402    }
     
    12921468}
    12931469
     1470// Import request (POST only).
     1471if ( '/synoveo/v1/import' === $route ) {
     1472    // Verify POST method.
     1473    // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Checking request method only.
     1474    $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : 'GET';
     1475    if ( 'POST' !== $method ) {
     1476        http_response_code( 405 );
     1477        header( 'Allow: POST' );
     1478        // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- SHORTINIT mode.
     1479        echo json_encode(
     1480            array(
     1481                'success' => false,
     1482                'error'   => 'Method not allowed. Use POST.',
     1483            )
     1484        );
     1485        exit;
     1486    }
     1487
     1488    $result  = synoveo_handle_import();
     1489    $elapsed = round( ( microtime( true ) - $start ) * 1000, 2 );
     1490
     1491    $status_code = $result['success'] ? 200 : 400;
     1492    http_response_code( $status_code );
     1493
     1494    // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- SHORTINIT mode.
     1495    echo json_encode(
     1496        array_merge(
     1497            $result,
     1498            array(
     1499                'meta' => array(
     1500                    'import_time_ms' => $elapsed,
     1501                    'method'         => 'direct-db',
     1502                ),
     1503            )
     1504        ),
     1505        JSON_PRETTY_PRINT
     1506    );
     1507    exit;
     1508}
     1509
     1510// Debug request.
     1511if ( '/synoveo/v1/debug' === $route ) {
     1512    $debug_info = synoveo_get_debug_info();
     1513    $elapsed    = round( ( microtime( true ) - $start ) * 1000, 2 );
     1514
     1515    // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- SHORTINIT mode.
     1516    echo json_encode(
     1517        array(
     1518            'success' => true,
     1519            'data'    => $debug_info,
     1520            'meta'    => array(
     1521                'debug_time_ms' => $elapsed,
     1522                'method'        => 'direct-db',
     1523            ),
     1524        ),
     1525        JSON_PRETTY_PRINT
     1526    );
     1527    exit;
     1528}
     1529
    12941530// Fallback.
    12951531http_response_code( 404 );
  • synoveo/trunk/includes/rest/handlers/class-synoveo-business-data-handler.php

    r3429757 r3433224  
    234234        // Dashboard uses these keys directly via composite key: {plugin}_{gbp_field}.
    235235        // See: docs/wordpress/singlePluginSource.md.
     236        //
     237        // IMPORTANT: Include BOTH short and full GBP paths for compatibility.
     238        // - Short: 'primaryPhone' (for legacy/internal use).
     239        // - Full: 'phoneNumbers.primaryPhone' (for delta comparison view).
    236240        $export_fields = array(
    237241            'title',
    238242            'primaryPhone',
     243            'phoneNumbers.primaryPhone',  // Full GBP path for delta comparison.
    239244            'email',
    240245            'profile.description',
     246            'websiteUri',
    241247            'addressLines',
    242248            'locality',
  • synoveo/trunk/includes/rest/handlers/class-synoveo-capabilities-handler.php

    r3429757 r3433224  
    199199                ),
    200200            ),
    201             // Social Media fields (mapped to GBP attributes).
     201            // Social Media fields (using internal profile.socialMedia naming).
    202202            'attributes/url_facebook'            => array(
    203203                array(
     
    234234                ),
    235235                array(
    236                     'plugin'       => 'seo-by-rank-math',
    237                     'display_name' => esc_html__( 'Rank Math (Instagram)', 'synoveo' ),
    238                 ),
    239                 array(
    240236                    'plugin'       => 'all-in-one-seo-pack',
    241237                    'display_name' => esc_html__( 'All in One SEO (Instagram)', 'synoveo' ),
     
    248244                ),
    249245                array(
    250                     'plugin'       => 'seo-by-rank-math',
    251                     'display_name' => esc_html__( 'Rank Math (LinkedIn)', 'synoveo' ),
    252                 ),
    253                 array(
    254246                    'plugin'       => 'all-in-one-seo-pack',
    255247                    'display_name' => esc_html__( 'All in One SEO (LinkedIn)', 'synoveo' ),
     
    262254                ),
    263255                array(
    264                     'plugin'       => 'seo-by-rank-math',
    265                     'display_name' => esc_html__( 'Rank Math (YouTube)', 'synoveo' ),
    266                 ),
    267                 array(
    268256                    'plugin'       => 'all-in-one-seo-pack',
    269257                    'display_name' => esc_html__( 'All in One SEO (YouTube)', 'synoveo' ),
     
    276264                ),
    277265                array(
    278                     'plugin'       => 'seo-by-rank-math',
    279                     'display_name' => esc_html__( 'Rank Math (Pinterest)', 'synoveo' ),
    280                 ),
    281                 array(
    282266                    'plugin'       => 'all-in-one-seo-pack',
    283267                    'display_name' => esc_html__( 'All in One SEO (Pinterest)', 'synoveo' ),
     
    286270            'attributes/url_tiktok'              => array(
    287271                array(
    288                     'plugin'       => 'wordpress-seo',
    289                     'display_name' => esc_html__( 'Yoast SEO (TikTok)', 'synoveo' ),
    290                 ),
    291                 array(
    292                     'plugin'       => 'seo-by-rank-math',
    293                     'display_name' => esc_html__( 'Rank Math (TikTok)', 'synoveo' ),
    294                 ),
    295                 array(
    296272                    'plugin'       => 'all-in-one-seo-pack',
    297273                    'display_name' => esc_html__( 'All in One SEO (TikTok)', 'synoveo' ),
     
    300276            'attributes/url_whatsapp'            => array(
    301277                array(
    302                     'plugin'       => 'wordpress-seo',
    303                     'display_name' => esc_html__( 'Yoast SEO (WhatsApp)', 'synoveo' ),
    304                 ),
    305                 array(
    306                     'plugin'       => 'seo-by-rank-math',
    307                     'display_name' => esc_html__( 'Rank Math (WhatsApp)', 'synoveo' ),
    308                 ),
    309                 array(
    310                     'plugin'       => 'all-in-one-seo-pack',
    311                     'display_name' => esc_html__( 'All in One SEO (WhatsApp)', 'synoveo' ),
    312                 ),
    313             ),
     278                    'plugin'       => 'synoveo',
     279                    'display_name' => esc_html__( 'Synoveo (WhatsApp)', 'synoveo' ),
     280                ),
     281            ),
     282            // WhatsApp: No WordPress plugin supports this - only in GBP attributes
    314283            // Service Items - GBP API field: locations.serviceItems[].
    315284            // Contains structuredServiceItem (Google predefined) or freeFormServiceItem (custom).
  • synoveo/trunk/includes/services/class-synoveo-option-mapping.php

    r3429757 r3433224  
    128128            'phoneNumbers.primaryPhone'     => 'aioseo_options:searchAppearance.global.schema.phone',
    129129            'profile.description'           => 'aioseo_options:searchAppearance.global.schema.organizationDescription',
    130             'websiteUri'                    => 'siteurl',
     130            'websiteUri'                    => 'aioseo_options:searchAppearance.global.schema.url',
    131131            'email'                         => 'aioseo_options:searchAppearance.global.schema.email',
    132132
     
    270270            // No WordPress plugin stores these. Synoveo is the only option.
    271271            'attributes'                      => 'synoveo_attributes',
     272            'attributes/url_whatsapp'         => 'synoveo_whatsapp_url',
    272273
    273274            // Service Area - for service-area businesses (plumbers, delivery, etc.).
  • synoveo/trunk/readme.txt

    r3431468 r3433224  
    1 === Synoveo – Business Profile Sync for Google ===
    2 Contributors: synoveo
     1=== Synoveo – Control Your Google Maps Listing ===
     2Contributors: Synoveo
    33Donate link: https://www.synoveo.com
    4 Tags: google, reviews, local-seo, woocommerce, schema
     4Tags: google my business, google maps, booking, local seo, schema
    55Requires at least: 6.2
    66Tested up to: 6.9
    77Requires PHP: 7.4
    8 Stable tag: 2.2.2
     8Stable tag: 2.3.1
    99License: GPLv2 or later
    1010
    11 Display Google reviews and sync your business info to Google Business Profile — automatically.
     11Your customers search Google Maps to find you. But what does Google show them?
    1212
    1313== Description ==
    1414
    15 Display Google reviews on your WordPress site and keep your Google Business Profile in sync — automatically.
     15If you don't tell Google who you are, Google decides for you. Using outdated info, Booking.com listings, or Airbnb pages instead of YOUR website.
    1616
    17 **Synoveo** connects your WordPress site to your Google Business Profile, making it easy to show reviews, post updates, and maintain accurate business information.
     17Synoveo makes your WordPress site the source of truth. You write your info once. Google Maps shows it.
    1818
    19 = What It Does =
     19= The Problem =
    2020
    21 * Display Google Business Profile reviews with shortcodes
    22 * Auto-post WordPress content to Google Business Profile (Pro)
    23 * Keep business info synced between WordPress and Google
    24 * Inject Schema.org structured data for better SEO (Pro)
    25 * Works with WooCommerce, RankMath, Yoast, and more
     21Google needs information about your business. If you don't provide it clearly, Google fills in the gaps:
    2622
    27 = Pricing Plans =
     23* Wrong hours from an old directory
     24* A description Google wrote for you
     25* Links to Booking.com or Airbnb instead of your website
     26* Phone numbers that don't work
    2827
    29 **FREE ($0/month)** – Perfect for getting started
    30 * Reviews & rating display (with Synoveo branding)
    31 * Manual refresh (1 per day)
     28Your customers get confused. They book somewhere else. They show up when you're closed. They blame you.
     29
     30= The Solution =
     31
     32Synoveo connects your WordPress site to Google Business Profile (formerly Google My Business).
     33
     34You write your business information once on your own website. Synoveo sends it to Google. Google Maps shows what YOU said.
     35
     36No more guessing. No more outdated info. No more sending customers to Booking.com or Expedia when you have your own booking system.
     37
     38= The Result =
     39
     40* Google Maps shows YOUR hours, YOUR description, YOUR links
     41* Customers go to YOUR website, not third party sites
     42* You control what Google says about your business
     43
     44**One sentence:** Synoveo makes sure Google repeats what's on your website instead of deciding for you.
     45
     46= What You Get =
     47
     48**FREE** Try it, see it work
     49
     50* See your real Google reviews on your website
     51* Update Google once a day with one click
    3252* 1 location
    3353
    34 **PRO ($9.90/month or $99/year — save 17%)** – Full automation
    35 * Automatic daily sync
    36 * Auto-post WordPress content to Google Business Profile
    37 * Schema.org structured data injection
    38 * No branding on reviews
    39 * Plugin integrations (RankMath, Yoast, WooCommerce, etc.)
    40 * Additional locations: +$5/month each
     54**PRO $9.90/month** Set it and forget it
    4155
    42 = How It Works =
    43 
    44 1. Install the plugin
    45 2. Create your account at app.synoveo.com
    46 3. Enter your API credentials
    47 4. Connect your Google Business Profile
    48 5. Reviews appear automatically — Pro users get daily sync
    49 
    50 **All the intelligence lives in the Synoveo platform** – no complex configuration needed.
    51 
    52 = Requirements =
    53 
    54 * WordPress 6.2 or higher
    55 * PHP 7.4 or higher (compatible with PHP 7.4, 8.0, 8.1, 8.2, 8.3, and 8.4)
    56 * Synoveo account (free plan available)
    57 * HTTPS enabled (for secure API communication)
     56* Google always has your latest info. You do nothing.
     57* Announce news, offers, events on Google Maps from WordPress
     58* Google understands your business better. You rank higher.
     59* Your site looks professional. No Synoveo branding.
     60* Multiple locations? Add $5/month each
     61* Pay yearly, save 17%
    5862
    5963== Installation ==
    6064
    61 1. Upload the plugin files to `/wp-content/plugins/synoveo/` or install directly from WordPress plugin search
    62 2. Activate the plugin through the 'Plugins' screen
    63 3. Go to Synoveo menu in WordPress admin
    64 4. Click "Open Synoveo Hub" to create your account at app.synoveo.com (use the same Gmail as your Google Business Profile)
    65 5. Choose a plan (Free for manual usage, Pro for full automation)
    66 6. In the Synoveo Hub, go to "API Keys" and generate credentials
    67 7. Copy your Client ID and Client Secret back to the plugin
    68 8. Click "Connect" to link your site
     651. Install Synoveo from the WordPress plugin directory
     662. Create your free account at app.synoveo.com (use the same email as your Google Business Profile)
     673. Copy your credentials into the plugin
     684. Done. Google now gets your info from your website.
     69
     70No technical setup. No field mapping. The plugin handles everything.
    6971
    7072== Frequently Asked Questions ==
    7173
    72 = Where do I get my credentials? =
     74= Why do I need this? =
    7375
    74 Log in to the Synoveo Hub at https://app.synoveo.com and go to the "API Keys" section to generate your Client ID and Client Secret.
     76When customers search Google Maps for your business, Google shows them information. If you don't control that information, Google uses whatever it finds: old directories, Booking.com, Airbnb, or its own guesses. Synoveo makes sure Google shows what YOU say.
    7577
    76 = What data does this plugin send? =
     78= What is Google Business Profile? =
    7779
    78 The plugin sends basic WordPress information (site name, URL, description, etc.) and WooCommerce data if installed (business hours, categories). All data is sent securely via HTTPS to the Synoveo platform where it's transformed to Google Business Profile format.
     80It is how your business appears on Google Maps and Google Search. You may know it as Google My Business. Same thing, new name.
    7981
    80 = Do I need to configure field mappings? =
     82= I already have a Google Business Profile. Why do I need Synoveo? =
    8183
    82 No! The Synoveo platform handles all field mapping and transformation. This plugin just sends raw WordPress data.
     84You can update Google manually. But your WordPress site already has your hours, your description, your links. Why type it twice? Synoveo sends your WordPress info to Google automatically. One source of truth.
    8385
    84 = Is my data secure? =
     86= Will this help me rank higher on Google Maps? =
    8587
    86 Yes. All communication uses HTTPS and JWT authentication. Your credentials are stored securely in WordPress options and encrypted tokens are used for API communication.
     88Yes. Google rewards businesses with complete, accurate, consistent information. When your website and Google Business Profile match perfectly, Google trusts you more.
    8789
    88 = What PHP versions are supported? =
     90= Do I need technical skills? =
    8991
    90 The plugin supports PHP 7.4 and all PHP 8.x versions (8.0, 8.1, 8.2, 8.3, 8.4). We include polyfills for PHP 8.0+ functions to ensure compatibility with older hosts.
     92No. Install the plugin, create an account, paste your credentials. That is all.
    9193
    92 = What happens to my data if I deactivate the plugin? =
     94= What if I have multiple locations? =
    9395
    94 When you deactivate the plugin, you'll be asked if you want to keep or delete your Synoveo data. If you choose to keep it, your settings will be preserved if you reinstall later. If you choose to delete, all credentials and settings will be removed when you delete the plugin.
     96Free plan includes 1 location. Pro plan lets you add more at $5/month each.
    9597
    96 = Can I use multiple WordPress sites with one Synoveo account? =
     98= Does it work with my booking system? =
    9799
    98 Yes! Each WordPress site needs its own API key from the Synoveo Hub. Your plan determines how many locations you can sync across all your sites.
     100Synoveo works with WooCommerce and most WordPress booking plugins. Your booking links go to YOUR system, not to Booking.com or Expedia.
    99101
    100 = Does this work with WooCommerce? =
     102= What about my Google reviews? =
    101103
    102 Yes, the plugin automatically detects WooCommerce and can sync relevant business information like store hours, categories, and product data to enhance your Google Business Profile.
     104Synoveo displays your Google reviews on your WordPress site. Show visitors what real customers say without them leaving your page.
    103105
    104 = How often does the plugin sync data? =
     106= Is my data safe? =
    105107
    106 Free users can manually refresh once per day. Pro users get automatic daily synchronization managed by the Synoveo platform, plus unlimited manual refreshes.
    107 
    108 = What's the difference between Free and Pro? =
    109 
    110 **Free** gives you reviews display with Synoveo branding, manual refresh (limited to 1/day), and 1 location. **Pro** ($9.90/month) removes branding, enables automatic daily sync, auto-posting to Google Business Profile, Schema.org injection, and plugin integrations. Save 17% with annual billing ($99/year).
    111 
    112 = What if I have issues connecting? =
    113 
    114 Check that your site has HTTPS enabled and your credentials are correct. Visit the Synoveo Hub for connection status and logs. Contact support@synoveo.com if you need help.
     108Yes. All communication is encrypted. Your Google credentials never touch our servers. We only send your public business information to Google.
    115109
    116110== Screenshots ==
    117111
    118 1. Dashboard – Connect your WordPress site to Synoveo platform
    119 2. Settings – Configure your API credentials and sync options
    120 3. Auto-Post (Pro) – Automatically publish WordPress posts to Google Business Profile
    121 4. Reviews – Display Google reviews on your site with shortcodes
     1121. Dashboard. See your connection status and Google reviews at a glance.
     1132. Your Google reviews displayed on your website. Real customers, real trust.
     1143. Post updates to Google Maps directly from WordPress. Announce offers, news, events.
     1154. Settings. Connect once, works forever.
    122116
    123117== Changelog ==
    124118
     119= 2.3.1 =
     120* Improved reliability for all plugin integrations (AIOSEO, RankMath, Yoast, WooCommerce, Bookly, Amelia)
     121* Better sync between Google and WordPress
     122
     123= 2.3.0 =
     124* 16x faster responses
     125* Two-way sync. Google data can now update your WordPress site.
     126* Support for Bookly and Amelia booking plugins
     127
    125128= 2.2.2 =
    126 * Fixed Schema.org review replies using correct `comment` property instead of non-standard `replyToUrl`
    127 * Added smart fallbacks for `priceRange` and `servesCuisine` covering all 112 GBP business types
    128 * Eliminates Google Rich Results validation warnings for missing optional fields
    129 * Fixed Google reconnect notification to redirect to plugin dashboard instead of external URL
    130 * Fixed Pro plan branding detection - Pro users no longer see "Powered by Synoveo" on reviews
    131 * Added plan validation with allowlist (security hardening)
    132 * Business plan users now correctly excluded from branding
    133 * Improved Schema.org LocalBusiness structured data compliance for better SEO
    134 
    135 = 2.2.1 =
    136 * Added manual refresh button for Free plan users on Reviews & Ratings page
    137 * Refresh syncs reviews, ratings, and business info from Google
    138 * Button shows quota status (1 refresh/day for Free, unlimited for Pro)
    139 * Automatic token refresh for reliable Google API connections
    140 
    141 = 2.2.0 =
    142 * Simplified pricing: Free and Pro plans (replaces previous 4-tier model)
    143 * Free plan: Reviews display with branding, manual refresh (1/day limit)
    144 * Pro plan: Automatic daily sync, auto-post, Schema.org, no branding ($9.90/mo or $99/yr)
    145 * Added "Powered by Synoveo" branding for Free plan users
    146 * Rate-limited manual refresh for Free users (1 per day)
    147 * Pro users get unlimited manual refreshes
    148 * Additional locations available as add-on ($5/month each)
    149 * Updated admin UI with clearer plan upgrade prompts
    150 * Improved plan-awareness trait for consistent feature gating
    151 
    152 = 2.1.0 =
    153 * Added deactivation feedback modal for better user experience
    154 * Data deletion choice integrated into deactivation flow
    155 * Improved uninstall cleanup with user preference
    156 * WordPress coding standards compliance updates
    157 
    158 = 2.0.8 =
    159 * Auto-post feature for automatic Google Business Profile posts
    160 * Gutenberg sidebar integration for post scheduling
    161 * REST API enhancements for post management
    162 
    163 = 2.0.4 =
    164 * Minimum WordPress version updated to 6.2 (for %i identifier placeholder support)
    165 * Verified PHP 7.4 – 8.4 compatibility
    166 * WordPress 6.9 compatibility
    167 * PHPCS WordPress coding standards compliance
    168 * Security hardening for REST API endpoints
    169 
    170 = 2.0.0 =
    171 * Complete refactor to bridge architecture
    172 * Streamlined codebase with improved modularity
    173 * Enhanced analyzer/detector integration with platform
    174 * Improved admin interface
    175 * Faster and more reliable
    176 
    177 = 1.2.4 =
    178 * Legacy version with complex architecture
     129* Better SEO. Google understands your reviews more clearly.
     130* Fixed branding for Pro users
    179131
    180132== Upgrade Notice ==
    181133
     134= 2.3.1 =
     135Improved reliability for keeping your Google info in sync.
     136
     137= 2.3.0 =
     138Major speed improvement. Your site responds 16x faster when talking to Google.
     139
    182140= 2.2.2 =
    183 Schema.org structured data now uses the correct `comment` property for business review replies, improving SEO compliance.
    184 
    185 = 2.2.1 =
    186 Free plan users can now manually refresh reviews and ratings directly from the WordPress admin. Find the refresh button on the Reviews & Ratings page.
    187 
    188 = 2.2.0 =
    189 Simplified pricing! Now just Free and Pro plans. Free includes reviews with branding and 1 manual refresh per day. Pro ($9.90/mo) adds automation, Schema.org, and removes branding. Upgrade at app.synoveo.com/dashboard/billing.
    190 
    191 = 2.1.0 =
    192 Improved deactivation experience with feedback collection and data deletion choice.
    193 
    194 = 2.0.4 =
    195 Requires WordPress 6.2+. PHP 7.4-8.4 compatibility verified. WordPress 6.9 tested. Security improvements.
    196 
    197 = 2.0.0 =
    198 Major simplification! The plugin is now just a simple bridge. Your existing credentials will continue to work.
     141Better SEO. Google now understands your business reviews more clearly.
  • synoveo/trunk/synoveo.php

    r3431468 r3433224  
    11<?php
    22/**
    3  * Plugin Name: Synoveo – Business Profile Sync for Google
     3 * Plugin Name: Synoveo – Control Your Google Maps Listing
    44 * Plugin URI: https://www.synoveo.com
    5  * Description: Connect Google Business Profile to WordPress. Sync posts, display reviews and ratings, and expose structured business data on your site. Compatible with Yoast, Rank Math, AIOSEO, and WooCommerce.
    6  * Version: 2.2.2
     5 * Description: Control what Google Maps shows about your business. Your WordPress site becomes the source of truth. Display reviews, post updates, stop Google from guessing.
     6 * Version: 2.3.1
    77 * Author: Synoveo
    88 * License: GPL v2 or later
     
    2020
    2121// Plugin constants.
    22 define( 'SYNOVEO_VERSION', '2.2.2' );
     22define( 'SYNOVEO_VERSION', '2.3.1' );
    2323define( 'SYNOVEO_PLUGIN_FILE', __FILE__ );
    2424define( 'SYNOVEO_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
Note: See TracChangeset for help on using the changeset viewer.