'string', 'sanitize_callback' => array( $this, 'sanitize_key' ), 'default' => '', ) ); register_setting( self::SETTINGS_GROUP, 'evoka_report_brand', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( self::SETTINGS_GROUP, 'evoka_org_sameas', array( 'type' => 'string', 'sanitize_callback' => array( $this, 'sanitize_sameas' ), 'default' => '', ) ); register_setting( self::SETTINGS_GROUP, 'evoka_org_knows_about', array( 'type' => 'string', 'sanitize_callback' => array( $this, 'sanitize_knows_about' ), 'default' => '', ) ); } /** * Sanitize a newline-separated textarea setting line by line. * * @param string $value Raw textarea value. * @param callable $per_line Per-line sanitizer. * @return string Newline-joined, cleaned value. */ private function sanitize_multiline( $value, $per_line ) { $lines = preg_split( '/[\r\n]+/', (string) $value ); $lines = array_map( 'trim', (array) $lines ); $lines = array_map( $per_line, $lines ); $lines = array_filter( $lines, function ( $l ) { return '' !== $l; } ); return implode( "\n", array_values( array_unique( $lines ) ) ); } /** * Sanitize the "sameAs" profile-URL textarea. * * @param string $value Raw value. * @return string */ public function sanitize_sameas( $value ) { return $this->sanitize_multiline( $value, 'esc_url_raw' ); } /** * Sanitize the "knowsAbout" topics textarea. * * @param string $value Raw value. * @return string */ public function sanitize_knows_about( $value ) { return $this->sanitize_multiline( $value, 'sanitize_text_field' ); } /** * Sanitize the settings array. * * @param array $input Raw input. * @return array */ public function sanitize_settings( $input ) { $out = array(); $out['schema_enabled'] = empty( $input['schema_enabled'] ) ? 0 : 1; $out['llms_txt_enabled'] = empty( $input['llms_txt_enabled'] ) ? 0 : 1; $policy = isset( $input['crawler_policy'] ) ? $input['crawler_policy'] : 'allow'; $out['crawler_policy'] = in_array( $policy, array( 'allow', 'track', 'block' ), true ) ? $policy : 'allow'; $provider = isset( $input['byok_provider'] ) ? $input['byok_provider'] : ''; $out['byok_provider'] = in_array( $provider, array( '', 'openai', 'perplexity' ), true ) ? $provider : ''; // No rewrite flush needed: the llms.txt rule is always registered and // the on/off switch is checked at request time. return $out; } /** * Sanitize the API key. * * The settings form never prints the saved key back into the page, so a * blank field on submit means "keep the current key"; the "Remove saved * key" checkbox clears it. These rules apply only to submissions from our * settings form; programmatic update_option() calls are stored as given. * * @param string $key Raw key. * @return string */ public function sanitize_key( $key ) { $key = trim( sanitize_text_field( (string) $key ) ); // Nonce already verified by options.php before sanitize callbacks run. // phpcs:disable WordPress.Security.NonceVerification.Missing $from_form = isset( $_POST['option_page'] ) && self::SETTINGS_GROUP === $_POST['option_page']; if ( $from_form ) { if ( ! empty( $_POST['evoka_api_key_clear'] ) ) { return ''; } if ( '' === $key ) { return (string) get_option( Evoka_Byok_Client::KEY_OPTION, '' ); } } // phpcs:enable return $key; } /** * Enqueue admin assets only on our screens. * * @param string $hook Current admin page hook. */ public function enqueue( $hook ) { if ( false === strpos( $hook, self::MENU_SLUG ) ) { return; } wp_enqueue_style( 'evoka-admin', EVOKA_URL . 'assets/admin.css', array(), EVOKA_VERSION ); wp_enqueue_script( 'evoka-admin', EVOKA_URL . 'assets/admin.js', array( 'jquery' ), EVOKA_VERSION, true ); // The report screen gets its own stylesheet and a small print handler, // both enqueued here rather than inlined in the page markup. if ( false !== strpos( $hook, Evoka_Report::PAGE_SLUG ) ) { wp_enqueue_style( 'evoka-report', EVOKA_URL . 'assets/report.css', array(), EVOKA_VERSION ); wp_add_inline_script( 'evoka-admin', 'document.addEventListener("click",function(e){if(e.target&&e.target.id==="evoka-print-report"){window.print();}});' ); } wp_localize_script( 'evoka-admin', 'evoka', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::NONCE ), 'i18n' => array( 'running' => __( 'Running audit...', 'evoka' ), 'error' => __( 'Something went wrong. Please try again.', 'evoka' ), 'asking' => __( 'Asking the AI...', 'evoka' ), 'check' => __( 'Check', 'evoka' ), 'runBulk' => __( 'Run bulk fix', 'evoka' ), 'working' => __( 'Working...', 'evoka' ), /* translators: 1: items processed so far, 2: total items */ 'progress' => __( '%1$s / %2$s processed', 'evoka' ), /* translators: %s: number of items processed */ 'bulkDone' => __( 'Done - %s items processed. Re-run the audit to see the new score.', 'evoka' ), 'confirmRevert' => __( 'Remove all FAQ and HowTo structured data added by the bulk fix? Your content is not affected.', 'evoka' ), /* translators: %s: number of items */ 'reverted' => __( 'Reverted %s items.', 'evoka' ), /* translators: %s: domain */ 'cited' => __( 'Yes - your site (%s) was mentioned in the answer.', 'evoka' ), /* translators: %s: domain */ 'notCited' => __( 'No - your site (%s) was NOT mentioned.', 'evoka' ), 'sources' => __( 'Sources the AI cited:', 'evoka' ), 'addTracked' => __( 'Add', 'evoka' ), 'adding' => __( 'Adding...', 'evoka' ), 'checking' => __( 'Checking...', 'evoka' ), 'checkNow' => __( 'Check now', 'evoka' ), 'remove' => __( 'Remove', 'evoka' ), 'confirmRemoveTracked' => __( 'Stop tracking this question? Its history will be deleted too.', 'evoka' ), 'trackedCited' => __( 'Cited', 'evoka' ), 'trackedNotCited' => __( 'Not cited', 'evoka' ), 'neverChecked' => __( 'Not checked yet', 'evoka' ), ), ) ); } /* * --------------------------------------------------------------------- * Screens * --------------------------------------------------------------------- */ /** * Render the dashboard screen. */ public function render_dashboard() { // Never run the (slow, loopback-based) audit while rendering the page. // With no cached result the view shows an empty state and admin.js // runs the audit over AJAX, then reloads. $engine = new Evoka_Audit_Engine(); $result = $engine->get_cached(); $byok = new Evoka_Byok_Client(); // Cheap indexed reads for a quick-glance preview of each feature. // Content Scores is NOT summarized here - scoring every post is only // cheap enough to run on its own dedicated page load, not on every // Dashboard visit (the Dashboard must stay fast). $history = (array) get_option( 'evoka_score_history', array() ); $visibility_summary = ( new Evoka_Visibility_Log() )->summary(); $tracked_count = count( ( new Evoka_Citation_Tracker() )->queries() ); include EVOKA_DIR . 'admin/views/dashboard.php'; } /** * Render the settings screen. */ public function render_settings() { $settings = get_option( 'evoka_settings', array() ); $api_key = (string) get_option( Evoka_Byok_Client::KEY_OPTION, '' ); $org_sameas = (string) get_option( 'evoka_org_sameas', '' ); $org_knows_about = (string) get_option( 'evoka_org_knows_about', '' ); // Only the last 4 characters are ever shown; the key itself never reaches the page. $key_hint = ( '' !== $api_key ) ? substr( $api_key, -4 ) : ''; unset( $api_key ); include EVOKA_DIR . 'admin/views/settings.php'; } /** * Render the AI Visibility Log screen. */ public function render_visibility_log() { $log = new Evoka_Visibility_Log(); $summary = $log->summary(); $recent = $log->recent( 50 ); include EVOKA_DIR . 'admin/views/visibility-log.php'; } /** * Render the Citation Tracking screen. */ public function render_citation_tracking() { $tracker = new Evoka_Citation_Tracker(); $queries = $tracker->queries(); $history = array(); foreach ( $queries as $q ) { $history[ $q['id'] ] = $tracker->history( $q['id'] ); } $byok = new Evoka_Byok_Client(); include EVOKA_DIR . 'admin/views/citation-tracking.php'; } /** * Render the Content Scores screen. */ public function render_content_scores() { $rows = ( new Evoka_Content_Score() )->score_all(); include EVOKA_DIR . 'admin/views/content-scores.php'; } /** * Render the client-ready report screen. */ public function render_report() { ( new Evoka_Report() )->render_screen(); } /* * --------------------------------------------------------------------- * AJAX * --------------------------------------------------------------------- */ /** * AJAX: run a fresh audit and return the result. */ public function ajax_run_audit() { $this->verify(); $engine = new Evoka_Audit_Engine(); $result = $engine->run( false ); wp_send_json_success( $result ); } /** * AJAX: run a BYOK citation check for a single query. */ public function ajax_check_citation() { $this->verify(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce + capability checked in verify() above. $query = isset( $_POST['query'] ) ? sanitize_text_field( wp_unslash( $_POST['query'] ) ) : ''; $client = new Evoka_Byok_Client(); $result = $client->check_citation( $query ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } wp_send_json_success( $result ); } /** * AJAX: process one batch of the bulk-fixer. */ public function ajax_bulk_fix() { $this->verify(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce + capability checked in verify() above. $offset = isset( $_POST['offset'] ) ? absint( wp_unslash( $_POST['offset'] ) ) : 0; $fixer = new Evoka_Bulk_Fixer(); $result = $fixer->process_batch( $offset ); $result['total'] = $fixer->total(); // Invalidate cached audit so the next view reflects the enhancements. delete_transient( Evoka_Audit_Engine::TRANSIENT_KEY ); wp_send_json_success( $result ); } /** * AJAX: revert all bulk-fix enhancements. */ public function ajax_bulk_revert() { $this->verify(); $fixer = new Evoka_Bulk_Fixer(); $cleared = $fixer->revert_all(); delete_transient( Evoka_Audit_Engine::TRANSIENT_KEY ); wp_send_json_success( array( 'cleared' => $cleared ) ); } /** * AJAX: add a tracked citation question. */ public function ajax_track_add() { $this->verify(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce + capability checked in verify() above. $query = isset( $_POST['query'] ) ? sanitize_text_field( wp_unslash( $_POST['query'] ) ) : ''; $tracker = new Evoka_Citation_Tracker(); $row = $tracker->add( $query ); if ( is_wp_error( $row ) ) { wp_send_json_error( array( 'message' => $row->get_error_message() ) ); } wp_send_json_success( $row ); } /** * AJAX: remove a tracked citation question. */ public function ajax_track_remove() { $this->verify(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce + capability checked in verify() above. $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : ''; $removed = ( new Evoka_Citation_Tracker() )->remove( $id ); wp_send_json_success( array( 'removed' => $removed ) ); } /** * AJAX: run one tracked question now. */ public function ajax_track_run() { $this->verify(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce + capability checked in verify() above. $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : ''; $point = ( new Evoka_Citation_Tracker() )->run_one( $id ); if ( is_wp_error( $point ) ) { wp_send_json_error( array( 'message' => $point->get_error_message() ) ); } wp_send_json_success( $point ); } /** * Verify nonce + capability for AJAX handlers. */ private function verify() { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'evoka' ) ), 403 ); } $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, self::NONCE ) ) { wp_send_json_error( array( 'message' => __( 'Expired session. Reload and try again.', 'evoka' ) ), 400 ); } } /* * --------------------------------------------------------------------- * Author profile fields (feed Person / E-E-A-T schema) * --------------------------------------------------------------------- */ /** * Output extra author fields. * * @param WP_User $user User being edited. */ public function author_fields( $user ) { if ( ! current_user_can( 'edit_user', $user->ID ) ) { return; } $job = get_the_author_meta( 'evoka_job_title', $user->ID ); $linkedin = get_the_author_meta( 'evoka_linkedin', $user->ID ); ?>