get_metasync_page_slug(); if (!$page_slug) { return; } // Add settings section for Google Index add_settings_section( self::SECTION_GOOGLE_INDEX, '', // Empty title - we'll use dashboard card styling function(){}, // Empty callback $page_slug . '_general' ); // Add the main settings field add_settings_field( 'google_index_direct_config', 'Google Index API', array($this, 'render_settings_field'), $page_slug . '_general', self::SECTION_GOOGLE_INDEX ); } /** * Get MetaSync page slug */ private function get_metasync_page_slug() { if (class_exists('Metasync_Admin') && property_exists('Metasync_Admin', 'page_slug')) { return Metasync_Admin::$page_slug; } return 'searchatlas'; // fallback } /** * Render the Google Index settings field */ public function render_settings_field() { // Load Google Index functionality if (!function_exists('google_index_direct')) { if (file_exists(plugin_dir_path(__FILE__) . 'google-index-init.php')) { require_once plugin_dir_path(__FILE__) . 'google-index-init.php'; } else { error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(__FILE__) . 'google-index-init.php'); return; } } // Get current service account info (safe - doesn't expose private key) $google_index = google_index_direct(); if (!$google_index) { // Nothing to render against, and calling through would be fatal. return; } $service_info = $google_index->get_service_account_info(); $is_configured = !isset($service_info['error']); $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : ''; // Include the settings field view include plugin_dir_path(__FILE__) . '../views/metasync-google-index-api-settings.php'; } /** * Process Google Index settings during MetaSync AJAX save * This runs early in the AJAX processing chain (priority 5) */ public function process_google_index_settings() { // Only process if our fields are present in the request if (!isset($_POST['google_index_service_account_json']) && !isset($_POST['google_index_clear_config']) && !isset($_FILES['google_index_service_account_file'])) { return; // No Google Index data to process } if (!$this->authorize_settings_request()) { return; } // Clearing does not need to load or inspect credential contents. if (isset($_POST['google_index_clear_config'])) { $this->clear_stored_service_account(); $this->add_settings_notice('Service account configuration cleared successfully!', 'success'); return; } $result = $this->persist_submitted_service_account(); if ($result['status'] === 'error') { // The parent save is still mid-flight; aborting here with an error // keeps a bad credential from being reported as a successful save. wp_send_json_error([ 'errors' => ['Google Index: ' . $result['message']], 'message' => 'Google Index: ' . $result['message'], ]); } if ($result['status'] === 'unavailable') { // The sub-module is missing. Let the rest of the settings save // finish and surface this as a notice instead of failing the page. $this->add_settings_notice($result['message'], 'warning'); return; } if ($result['status'] === 'saved') { $this->add_settings_notice('Google Index service account configured successfully!', 'success'); } } /** * Read the submitted service account from $_POST/$_FILES, validate it, and * persist it when it is a genuinely new credential. * * Shared by the form-embedded flow and the dedicated standalone endpoint so * both accept and reject exactly the same payloads. Returns a status rather * than emitting a response, because the two callers report differently: the * embedded flow must abort the parent save, the dedicated endpoint owns the * whole response. * * @return array{status:string, message:string} status is one of: * 'saved' - a new credential was written * 'unchanged' - nothing to write (empty, or the redacted display text) * 'error' - validation or persistence failed; message explains why */ private function persist_submitted_service_account() { // Load Google Index functionality if (!$this->module_function_available('google_index_save_service_account')) { if (file_exists(plugin_dir_path(__FILE__) . 'google-index-init.php')) { require_once plugin_dir_path(__FILE__) . 'google-index-init.php'; } // A partial deploy can leave the file present but the function // undefined, so re-check rather than assuming the require worked. if (!$this->module_function_available('google_index_save_service_account')) { error_log('MetaSync Google Index: google_index_save_service_account() unavailable; expected google-index-init.php at ' . plugin_dir_path(__FILE__) . 'google-index-init.php'); // Deliberately NOT 'error'. This optional sub-module failing to // load must not abort the parent settings save — the embedded // caller would turn that into a wp_send_json_error() and take // the whole General Settings / Indexation Control page down. return ['status' => 'unavailable', 'message' => 'The Google Index module could not be loaded, so the service account was not saved.']; } } $service_account_json = ''; // Get JSON from textarea if (isset($_POST['google_index_service_account_json'])) { $service_account_json = sanitize_textarea_field(wp_unslash($_POST['google_index_service_account_json'])); } // Override with file upload if provided if (isset($_FILES['google_index_service_account_file']) && !empty($_FILES['google_index_service_account_file']['tmp_name']) && file_exists($_FILES['google_index_service_account_file']['tmp_name'])) { $uploaded_json = file_get_contents($_FILES['google_index_service_account_file']['tmp_name']); if ($uploaded_json !== false) { $service_account_json = $uploaded_json; // Removed unnecessary wp_unslash } } if (empty($service_account_json) || trim($service_account_json) === '') { return ['status' => 'unchanged', 'message' => '']; } // Skip if it contains redacted private key (user didn't paste new JSON) if (strpos($service_account_json, self::REDACTED_MARKER) !== false) { return ['status' => 'unchanged', 'message' => '']; } $validation = $this->validate_service_account_json($service_account_json); if (!empty($validation['error'])) { return ['status' => 'error', 'message' => $validation['error']]; } if (!google_index_save_service_account($validation['data'])) { return [ 'status' => 'error', 'message' => 'Failed to save service account configuration. Please try again or check your JSON format.', ]; } return ['status' => 'saved', 'message' => 'Google Index service account configured successfully!']; } /** * Whether a function the optional Google Index sub-module provides exists. * * Taking the name as a parameter keeps the check opaque to static analysis, * which otherwise treats the second call in a load-then-verify pair as dead * because it cannot see that the intervening require_once may define it. * * Genuinely impure: the same argument yields a different answer once the * sub-module has been required, which is precisely why it is called twice. * * @phpstan-impure * * @param string $name Function name to look for. * @return bool */ private function module_function_available($name) { return function_exists($name); } /** * Decode and structurally validate a service account JSON string. * * @param string $json Raw JSON text. * @return array{data:array|null, error:string} error is '' when valid. */ private function validate_service_account_json($json) { $data = json_decode($json, true); if (json_last_error() !== JSON_ERROR_NONE) { $message = 'Invalid JSON format'; switch (json_last_error()) { case JSON_ERROR_SYNTAX: $message .= ' - Syntax error in JSON'; break; case JSON_ERROR_UTF8: $message .= ' - Invalid UTF-8 encoding'; break; default: $message .= ' - ' . json_last_error_msg(); break; } return ['data' => null, 'error' => $message . '. Please check your service account JSON.']; } if (!is_array($data)) { return [ 'data' => null, 'error' => 'JSON must be an object/array. Please check your service account JSON format.', ]; } $required_fields = ['type', 'project_id', 'private_key_id', 'private_key', 'client_email', 'client_id', 'auth_uri', 'token_uri']; $missing_fields = []; foreach ($required_fields as $field) { if (!isset($data[$field]) || empty($data[$field])) { $missing_fields[] = $field; } } if (!empty($missing_fields)) { return [ 'data' => null, 'error' => 'Missing required fields - ' . implode(', ', $missing_fields) . '. Please ensure you have a complete service account JSON.', ]; } return ['data' => $data, 'error' => '']; } /** * AJAX: save the service account from the standalone Instant Indexing page. * * The page has no settings form, so this endpoint carries its own nonce and * capability check rather than relying on a parent save's. */ public function ajax_save_service_account() { $this->authorize_account_request(); $result = $this->persist_submitted_service_account(); if ($result['status'] === 'error' || $result['status'] === 'unavailable') { // Here the credential save IS the whole request, so a missing // sub-module is a genuine failure and must be reported as one. wp_send_json_error([ 'message' => $result['message'], 'errors' => [$result['message']], ]); } if ($result['status'] === 'unchanged') { // Nothing was submitted, or the textarea still holds the redacted // text the server rendered. Reporting this plainly is better than a // success banner for a save that wrote nothing. wp_send_json_success([ 'message' => 'No new service account JSON was provided, so the saved configuration is unchanged.', 'saved' => false, ]); } // Survives the page reload the JS performs, which is what actually // renders the confirmation banner and the configured-state UI. $this->add_settings_notice('Google Index service account configured successfully!', 'success'); wp_send_json_success([ 'message' => 'Service account saved successfully!', 'saved' => true, ]); } /** * AJAX: clear the stored service account from the standalone page. */ public function ajax_clear_service_account() { $this->authorize_account_request(); $this->clear_stored_service_account(); $this->add_settings_notice('Service account configuration cleared successfully!', 'success'); wp_send_json_success(['message' => 'Service account configuration cleared successfully!']); } /** * Remove the stored service account and any token minted from it. * * Shared by both clear paths: a cached access token that outlives the * credential it was issued for would keep working until it expired. */ private function clear_stored_service_account() { delete_option('google_index_service_account'); if (function_exists('google_index_direct')) { $instance = google_index_direct(); if ($instance) { $instance->clear_token_cache(); } } } /** * Authorize a dedicated (standalone-page) service account request. * * Terminates the request with a 403 JSON response when the caller lacks the * capability or a valid nonce. */ private function authorize_account_request() { if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => 'Insufficient permissions.'), 403); } $nonce = isset($_POST['metasync_google_index_account_nonce']) ? wp_unslash($_POST['metasync_google_index_account_nonce']) : ''; if (!$nonce || !wp_verify_nonce($nonce, self::ACCOUNT_NONCE_ACTION)) { wp_send_json_error(array('message' => 'Invalid nonce'), 403); } return true; } /** * Add a settings notice to be displayed after AJAX save * * @param string $message Notice message * @param string $type Notice type: 'success', 'error', 'warning', 'info' */ private function add_settings_notice($message, $type = 'info') { $notices = get_transient('google_index_admin_notices') ?: []; $notices[] = [ 'message' => $message, 'type' => $type, 'time' => time() ]; // Store notices for 30 seconds (enough time for page redirect) set_transient('google_index_admin_notices', $notices, 30); } /** * Display admin notices for Google Index settings */ public function display_admin_notices() { // Only show notices on MetaSync settings pages $page_slug = $this->get_metasync_page_slug(); if ( ! function_exists( 'get_current_screen' ) ) { return; } $current_screen = get_current_screen(); if (!$current_screen || strpos($current_screen->id, $page_slug) === false) { return; } // Get and display notices $notices = get_transient('google_index_admin_notices'); if (!empty($notices)) { foreach ($notices as $notice) { $class = 'notice notice-' . esc_attr($notice['type']) . ' is-dismissible'; echo '
Google Index: ' . esc_html($notice['message']) . '
'; echo '').text(text)); box.append(notice); } function showSaveMessage(text, type) { var box = $('#google-index-save-messages'); if (!box.length) { // Embedded contexts have no dedicated box; fall back to the // file-message area so errors are never silent. showFileMessage(text, type); return; } box.empty(); if (!text) { return; } var notice = $('
').text(text)); box.append(notice); } function clearSelectedFile(restoreTextarea) { fileReadPending = false; var input = $('#google_index_service_account_file'); if (input.length) { input.val(''); } $('#google-index-selected-file').hide(); $('#google-index-selected-file-name').text(''); showFileMessage('', 'info'); if (restoreTextarea) { var textarea = $('#google_index_service_account_json'); textarea.val(originalJsonValue); textarea.trigger('input').trigger('change'); $('#metaSyncGeneralSetting, #metaSyncSeoControlsForm').trigger('change'); } } // Integration with MetaSync's unsaved changes detection function integrateWithUnsavedChangesDetection() { // Monitor textarea for changes (avoid recursion) $('#google_index_service_account_json').on('input change paste keyup', function(e) { // Trigger MetaSync's change detection (works on both Settings and Indexation Control pages) $('#metaSyncGeneralSetting, #metaSyncSeoControlsForm').trigger('change'); }); // Handle file upload with auto-populate and change detection $('#google_index_service_account_file').on('change', function(e) { var file = e.target.files[0]; var textarea = $('#google_index_service_account_json'); if (!file) { clearSelectedFile(true); return; } if (!file.name.toLowerCase().endsWith('.json')) { // Inline feedback instead of the old blocking alert(). showFileMessage('\"' + file.name + '\" is not a .json file. Choose the service account key file you downloaded from Google Cloud.', 'error'); $('#google-index-selected-file').hide(); $('#google_index_service_account_file').val(''); return; } // Snapshot only when no file is currently selected, i.e. at // the START of a pick sequence. Re-snapshotting on every // pick would make Remove restore file A's contents after // the user picked A then B then removed — leaving a // credential in the box while the UI says no file is // selected. if (!$('#google-index-selected-file').is(':visible')) { originalJsonValue = textarea.val(); } var reader = new FileReader(); fileReadPending = true; reader.onload = function(ev) { fileReadPending = false; try { var json = JSON.parse(ev.target.result); var jsonString = JSON.stringify(json, null, 2); textarea.val(jsonString); $('#google-index-selected-file-name').text(file.name); $('#google-index-selected-file').show(); showFileMessage('\"' + file.name + '\" loaded. Click Save Configuration to store it.', 'info'); // Create and dispatch native events to ensure proper detection setTimeout(function() { var inputEvent = new Event('input', { bubbles: true, cancelable: true }); var changeEvent = new Event('change', { bubbles: true, cancelable: true }); textarea[0].dispatchEvent(inputEvent); textarea[0].dispatchEvent(changeEvent); textarea.trigger('input').trigger('change'); }, 100); } catch (err) { showFileMessage('\"' + file.name + '\" is not valid JSON. Re-download the key file from Google Cloud and try again.', 'error'); $('#google-index-selected-file').hide(); $('#google_index_service_account_file').val(''); } }; reader.onerror = function() { fileReadPending = false; showFileMessage('\"' + file.name + '\" could not be read. Try choosing it again.', 'error'); $('#google-index-selected-file').hide(); $('#google_index_service_account_file').val(''); }; reader.readAsText(file); }); // Remove/reselect $('#google-index-remove-file').on('click', function(e) { e.preventDefault(); clearSelectedFile(true); }); } // Initialize integration after a small delay to ensure MetaSync is ready setTimeout(integrateWithUnsavedChangesDetection, 100); // Dedicated save — standalone Instant Indexing page only. $('#google-index-save-config').on('click', function(e) { e.preventDefault(); if (fileReadPending) { showSaveMessage('Still reading the selected file. Try again in a moment.', 'warning'); return; } var button = $(this); var original = button.html(); button.prop('disabled', true).text('Saving...'); showSaveMessage('', 'info'); $.ajax({ url: (typeof ajaxurl !== 'undefined') ? ajaxurl : metaSync.ajax_url, type: 'POST', data: { action: 'metasync_google_index_save_account', metasync_google_index_account_nonce: $('#metasync_google_index_account_nonce').val(), google_index_service_account_json: $('#google_index_service_account_json').val() }, success: function(response) { if (response && response.success) { if (response.data && response.data.saved) { // Reload so the configured-state UI and the // transient success banner both render. window.location.reload(); return; } showSaveMessage((response.data && response.data.message) || 'Nothing to save.', 'warning'); } else { var msg = (response && response.data && response.data.message) ? response.data.message : 'Failed to save the service account.'; showSaveMessage(msg, 'error'); } }, error: function() { showSaveMessage('Network error while saving. Please try again.', 'error'); }, complete: function() { button.prop('disabled', false).html(original); } }); }); // Handle test connection button $('#google-index-test-connection').on('click', function(e) { e.preventDefault(); var button = $(this); var resultDiv = $('#google-index-test-results'); button.prop('disabled', true).text('🔄 Testing...'); resultDiv.html('
Testing connection...
✅ Connection Test Results:
'; html += '❌ Test Failed: ' + escHtml(response.data.message) + '
❌ Connection Error: Unable to perform test.